Implement JWT authentication and app passkey support
- Add JWT token generation and validation - Implement bcrypt password hashing - Create auth service with register/login/refresh/logout - Add app passkey generation and management - Implement protected routes and auth middleware - Add comprehensive tests for new functionality
This commit is contained in:
101
internal/repository/credential.go
Normal file
101
internal/repository/credential.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
)
|
||||
|
||||
// CredentialRepository provides access to alternative credential records.
|
||||
type CredentialRepository interface {
|
||||
Create(ctx context.Context, cred *model.Credential) error
|
||||
FindByID(ctx context.Context, id string) (*model.Credential, error)
|
||||
FindByUserID(ctx context.Context, userID string) ([]model.Credential, error)
|
||||
FindByUserIDAndType(ctx context.Context, userID, credType string) ([]model.Credential, error)
|
||||
FindByHash(ctx context.Context, hash string) (*model.Credential, error)
|
||||
UpdateLastUsed(ctx context.Context, id string) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
type credentialRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewCredentialRepository creates a CredentialRepository backed by GORM.
|
||||
func NewCredentialRepository(db *gorm.DB) CredentialRepository {
|
||||
return &credentialRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *credentialRepository) Create(ctx context.Context, cred *model.Credential) error {
|
||||
result := r.db.WithContext(ctx).Create(cred)
|
||||
if result.Error != nil {
|
||||
if isDuplicateKeyError(result.Error) {
|
||||
return model.ErrDuplicate
|
||||
}
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *credentialRepository) FindByID(ctx context.Context, id string) (*model.Credential, error) {
|
||||
var cred model.Credential
|
||||
result := r.db.WithContext(ctx).First(&cred, "id = ?", id)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return &cred, nil
|
||||
}
|
||||
|
||||
func (r *credentialRepository) FindByUserID(ctx context.Context, userID string) ([]model.Credential, error) {
|
||||
var creds []model.Credential
|
||||
result := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&creds)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
func (r *credentialRepository) FindByUserIDAndType(ctx context.Context, userID, credType string) ([]model.Credential, error) {
|
||||
var creds []model.Credential
|
||||
result := r.db.WithContext(ctx).Where("user_id = ? AND type = ?", userID, credType).Find(&creds)
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return creds, nil
|
||||
}
|
||||
|
||||
func (r *credentialRepository) FindByHash(ctx context.Context, hash string) (*model.Credential, error) {
|
||||
var cred model.Credential
|
||||
result := r.db.WithContext(ctx).First(&cred, "secret_hash = ?", hash)
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, model.ErrNotFound
|
||||
}
|
||||
if result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
return &cred, nil
|
||||
}
|
||||
|
||||
func (r *credentialRepository) UpdateLastUsed(ctx context.Context, id string) error {
|
||||
now := time.Now()
|
||||
result := r.db.WithContext(ctx).Model(&model.Credential{}).Where("id = ?", id).Update("last_used_at", now)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *credentialRepository) Delete(ctx context.Context, id string) error {
|
||||
result := r.db.WithContext(ctx).Delete(&model.Credential{}, "id = ?", id)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
194
internal/repository/credential_test.go
Normal file
194
internal/repository/credential_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
)
|
||||
|
||||
func setupCredentialRepo(t *testing.T) CredentialRepository {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Credential{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
return NewCredentialRepository(db)
|
||||
}
|
||||
|
||||
func TestCredentialRepository_Create(t *testing.T) {
|
||||
repo := setupCredentialRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
cred := &model.Credential{
|
||||
ID: "cred-1",
|
||||
UserID: "user-1",
|
||||
Type: "app_passkey",
|
||||
Label: "My Phone",
|
||||
SecretHash: "hash-abc",
|
||||
}
|
||||
|
||||
if err := repo.Create(ctx, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialRepository_CreateDuplicateHash(t *testing.T) {
|
||||
repo := setupCredentialRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
c1 := &model.Credential{ID: "cred-1", UserID: "user-1", Type: "app_passkey", Label: "A", SecretHash: "hash-abc"}
|
||||
c2 := &model.Credential{ID: "cred-2", UserID: "user-1", Type: "app_passkey", Label: "B", SecretHash: "hash-abc"}
|
||||
|
||||
if err := repo.Create(ctx, c1); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
err := repo.Create(ctx, c2)
|
||||
if err != model.ErrDuplicate {
|
||||
t.Fatalf("expected ErrDuplicate, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialRepository_FindByID(t *testing.T) {
|
||||
repo := setupCredentialRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
cred := &model.Credential{ID: "cred-1", UserID: "user-1", Type: "app_passkey", Label: "Phone", SecretHash: "h1"}
|
||||
if err := repo.Create(ctx, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
found, err := repo.FindByID(ctx, "cred-1")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByID = %v", err)
|
||||
}
|
||||
if found.Label != "Phone" {
|
||||
t.Errorf("Label = %q, want %q", found.Label, "Phone")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialRepository_FindByIDNotFound(t *testing.T) {
|
||||
repo := setupCredentialRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := repo.FindByID(ctx, "nonexistent")
|
||||
if err != model.ErrNotFound {
|
||||
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialRepository_FindByUserID(t *testing.T) {
|
||||
repo := setupCredentialRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
c1 := &model.Credential{ID: "c-1", UserID: "user-1", Type: "app_passkey", Label: "A", SecretHash: "h1"}
|
||||
c2 := &model.Credential{ID: "c-2", UserID: "user-1", Type: "app_passkey", Label: "B", SecretHash: "h2"}
|
||||
c3 := &model.Credential{ID: "c-3", UserID: "user-2", Type: "app_passkey", Label: "C", SecretHash: "h3"}
|
||||
|
||||
for _, c := range []*model.Credential{c1, c2, c3} {
|
||||
if err := repo.Create(ctx, c); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
creds, err := repo.FindByUserID(ctx, "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserID = %v", err)
|
||||
}
|
||||
if len(creds) != 2 {
|
||||
t.Errorf("len(creds) = %d, want 2", len(creds))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialRepository_FindByUserIDAndType(t *testing.T) {
|
||||
repo := setupCredentialRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
c1 := &model.Credential{ID: "c-1", UserID: "user-1", Type: "app_passkey", Label: "A", SecretHash: "h1"}
|
||||
c2 := &model.Credential{ID: "c-2", UserID: "user-1", Type: "oauth", Label: "Github", SecretHash: "h2"}
|
||||
|
||||
for _, c := range []*model.Credential{c1, c2} {
|
||||
if err := repo.Create(ctx, c); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
passkeys, err := repo.FindByUserIDAndType(ctx, "user-1", "app_passkey")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDAndType = %v", err)
|
||||
}
|
||||
if len(passkeys) != 1 {
|
||||
t.Errorf("len(passkeys) = %d, want 1", len(passkeys))
|
||||
}
|
||||
if passkeys[0].Type != "app_passkey" {
|
||||
t.Errorf("type = %q, want %q", passkeys[0].Type, "app_passkey")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialRepository_FindByHash(t *testing.T) {
|
||||
repo := setupCredentialRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
cred := &model.Credential{ID: "c-1", UserID: "user-1", Type: "app_passkey", Label: "Phone", SecretHash: "hash-find"}
|
||||
if err := repo.Create(ctx, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
found, err := repo.FindByHash(ctx, "hash-find")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByHash = %v", err)
|
||||
}
|
||||
if found.UserID != "user-1" {
|
||||
t.Errorf("UserID = %q, want %q", found.UserID, "user-1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialRepository_UpdateLastUsed(t *testing.T) {
|
||||
repo := setupCredentialRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
cred := &model.Credential{ID: "c-1", UserID: "user-1", Type: "app_passkey", Label: "Phone", SecretHash: "h1"}
|
||||
if err := repo.Create(ctx, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.UpdateLastUsed(ctx, "c-1"); err != nil {
|
||||
t.Fatalf("UpdateLastUsed = %v", err)
|
||||
}
|
||||
|
||||
found, err := repo.FindByID(ctx, "c-1")
|
||||
if err != nil {
|
||||
t.Fatalf("FindByID = %v", err)
|
||||
}
|
||||
if found.LastUsedAt == nil {
|
||||
t.Fatal("LastUsedAt should not be nil after update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCredentialRepository_Delete(t *testing.T) {
|
||||
repo := setupCredentialRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
cred := &model.Credential{ID: "c-1", UserID: "user-1", Type: "app_passkey", Label: "Phone", SecretHash: "h1"}
|
||||
if err := repo.Create(ctx, cred); err != nil {
|
||||
t.Fatalf("Create = %v", err)
|
||||
}
|
||||
|
||||
if err := repo.Delete(ctx, "c-1"); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
_, err := repo.FindByID(ctx, "c-1")
|
||||
if err != model.ErrNotFound {
|
||||
t.Fatalf("expected ErrNotFound after delete, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -53,5 +53,6 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&model.User{},
|
||||
&model.Session{},
|
||||
&model.File{},
|
||||
&model.Credential{},
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user