feat(repository): refactor query/mutation ports with capability-safe
interfaces - refactor: split UserRepository into AuthUserRepository and AdminUserRepository capabilities - refactor: split SessionRepository into AuthSessionRepository and repository-owned CLI/admin methods - refactor: replace generic Repository Update/Delete with operation-specific params and ownership predicates - refactor: replace CredentialRepository generic CRUD with passkey-specific methods - refactor: replace FileRepository generic Create/Update/Delete with UploadedFileParams, DirectoryParams, and owned soft-delete - refactor: remove repository fields from WebApp struct; repositories are now composition-time wiring only - feat: add domain error kinds ErrParentNotFound, ErrParentNotDir, ErrDirectoryNotEmpty, ErrInvalidMove - feat: add CredentialTypeAppPasskey constant - feat: add testutil.SetUserAdmin for test fixture setup that bypasses production service ports - test: add architecture test banning GORM Save in repository package - test: add capability interface contract tests ensuring each service receives the minimal interface - test: add blockingStorage helper for concurrent promotion tests - test: add preserved DSN parameter test for sqliteImmediateDSN - docs: update architecture decisions with repository write rules and capability separation - docs: update roadmap to clarify atomic single-use refresh sessions - docs: add -race test target to development docs
This commit is contained in:
+31
-44
@@ -34,8 +34,8 @@ type Principal struct {
|
||||
|
||||
// AuthService handles user authentication and session management.
|
||||
type AuthService struct {
|
||||
userRepo repository.UserRepository
|
||||
sessionRepo repository.SessionRepository
|
||||
userRepo repository.AuthUserRepository
|
||||
sessionRepo repository.AuthSessionRepository
|
||||
credentialRepo repository.CredentialRepository
|
||||
jwtSecret []byte
|
||||
accessTTL time.Duration
|
||||
@@ -44,8 +44,8 @@ type AuthService struct {
|
||||
|
||||
// NewAuthService creates an AuthService.
|
||||
func NewAuthService(
|
||||
userRepo repository.UserRepository,
|
||||
sessionRepo repository.SessionRepository,
|
||||
userRepo repository.AuthUserRepository,
|
||||
sessionRepo repository.AuthSessionRepository,
|
||||
credentialRepo repository.CredentialRepository,
|
||||
jwtSecret []byte,
|
||||
accessTTL time.Duration,
|
||||
@@ -72,15 +72,13 @@ func (s *AuthService) Register(ctx context.Context, username, email, password st
|
||||
return nil, model.NewInternalError("hash password", err)
|
||||
}
|
||||
|
||||
user := &model.User{
|
||||
user, err := s.userRepo.CreateRegisteredUser(ctx, repository.RegisteredUserParams{
|
||||
ID: uuid.NewString(),
|
||||
Username: username,
|
||||
Email: email,
|
||||
PasswordHash: passwordHash,
|
||||
Status: model.StatusActive,
|
||||
}
|
||||
|
||||
if err := s.userRepo.Create(ctx, user); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrDuplicate) {
|
||||
return nil, model.NewConflictError("username or email already exists")
|
||||
}
|
||||
@@ -124,7 +122,7 @@ func (s *AuthService) Refresh(ctx context.Context, refreshTokenStr string) (*Tok
|
||||
}
|
||||
|
||||
tokenHash := auth.HashToken(refreshTokenStr)
|
||||
session, err := s.sessionRepo.FindByTokenHash(ctx, tokenHash)
|
||||
session, err := s.sessionRepo.ConsumeRefreshSession(ctx, tokenHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.NewUnauthenticatedError("invalid token")
|
||||
@@ -144,25 +142,13 @@ func (s *AuthService) Refresh(ctx context.Context, refreshTokenStr string) (*Tok
|
||||
return nil, model.NewUnauthenticatedError("invalid token")
|
||||
}
|
||||
|
||||
if err := s.sessionRepo.Delete(ctx, session.ID); err != nil {
|
||||
return nil, model.NewInternalError("delete old session", err)
|
||||
}
|
||||
|
||||
return s.issueTokens(ctx, claims.UserID)
|
||||
}
|
||||
|
||||
// Logout invalidates a refresh token by deleting its session.
|
||||
func (s *AuthService) Logout(ctx context.Context, refreshTokenStr string) error {
|
||||
tokenHash := auth.HashToken(refreshTokenStr)
|
||||
session, err := s.sessionRepo.FindByTokenHash(ctx, tokenHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
return model.NewInternalError("find session", err)
|
||||
}
|
||||
|
||||
if err := s.sessionRepo.Delete(ctx, session.ID); err != nil {
|
||||
if err := s.sessionRepo.RevokeRefreshSession(ctx, tokenHash); err != nil {
|
||||
return model.NewInternalError("delete session", err)
|
||||
}
|
||||
return nil
|
||||
@@ -175,20 +161,18 @@ func (s *AuthService) CreatePasskey(ctx context.Context, userID, label string) (
|
||||
return nil, model.NewInternalError("generate token", err)
|
||||
}
|
||||
|
||||
cred := &model.Credential{
|
||||
ID: uuid.NewString(),
|
||||
credID := uuid.NewString()
|
||||
if err := s.credentialRepo.CreatePasskey(ctx, repository.PasskeyParams{
|
||||
ID: credID,
|
||||
UserID: userID,
|
||||
Type: "app_passkey",
|
||||
Label: label,
|
||||
SecretHash: hash,
|
||||
}
|
||||
|
||||
if err := s.credentialRepo.Create(ctx, cred); err != nil {
|
||||
}); err != nil {
|
||||
return nil, model.NewInternalError("create credential", err)
|
||||
}
|
||||
|
||||
return &CreatedPasskey{
|
||||
ID: cred.ID,
|
||||
ID: credID,
|
||||
Raw: raw,
|
||||
Label: label,
|
||||
}, nil
|
||||
@@ -201,7 +185,7 @@ func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*T
|
||||
}
|
||||
|
||||
tokenHash := auth.HashToken(tokenStr)
|
||||
cred, err := s.credentialRepo.FindByHash(ctx, tokenHash)
|
||||
cred, err := s.credentialRepo.FindPasskeyByHash(ctx, tokenHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.NewUnauthenticatedError("invalid passkey")
|
||||
@@ -209,19 +193,21 @@ func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*T
|
||||
return nil, model.NewInternalError("find credential", err)
|
||||
}
|
||||
|
||||
user, err := s.userRepo.FindByIDIncludeDeleted(ctx, cred.UserID)
|
||||
user, err := s.userRepo.FindByID(ctx, cred.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.NewUnauthenticatedError("invalid passkey")
|
||||
}
|
||||
return nil, model.NewInternalError("find user", err)
|
||||
}
|
||||
if user.Status != model.StatusActive {
|
||||
return nil, model.NewUnauthenticatedError("invalid passkey")
|
||||
}
|
||||
|
||||
if cred.Type != "app_passkey" {
|
||||
return nil, model.NewUnauthenticatedError("invalid credential type")
|
||||
}
|
||||
|
||||
if err := s.credentialRepo.UpdateLastUsed(ctx, cred.ID); err != nil {
|
||||
if err := s.credentialRepo.RecordPasskeyUsed(ctx, cred.ID); err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.NewUnauthenticatedError("invalid passkey")
|
||||
}
|
||||
return nil, model.NewInternalError("update last used", err)
|
||||
}
|
||||
|
||||
@@ -230,7 +216,7 @@ func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*T
|
||||
|
||||
// ListPasskeys returns all app passkeys for a user.
|
||||
func (s *AuthService) ListPasskeys(ctx context.Context, userID string) ([]model.Credential, error) {
|
||||
creds, err := s.credentialRepo.FindByUserIDAndType(ctx, userID, "app_passkey")
|
||||
creds, err := s.credentialRepo.ListPasskeys(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, model.NewInternalError("list passkeys", err)
|
||||
}
|
||||
@@ -239,7 +225,7 @@ func (s *AuthService) ListPasskeys(ctx context.Context, userID string) ([]model.
|
||||
|
||||
// RevokePasskey deletes an app passkey owned by the user.
|
||||
func (s *AuthService) RevokePasskey(ctx context.Context, userID, credID string) error {
|
||||
cred, err := s.credentialRepo.FindByID(ctx, credID)
|
||||
cred, err := s.credentialRepo.FindPasskeyByID(ctx, credID)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return model.NewNotFoundError("passkey not found", model.ErrNotFound)
|
||||
@@ -251,7 +237,10 @@ func (s *AuthService) RevokePasskey(ctx context.Context, userID, credID string)
|
||||
return model.NewPermissionDeniedError("access denied", model.ErrForbidden)
|
||||
}
|
||||
|
||||
if err := s.credentialRepo.Delete(ctx, credID); err != nil {
|
||||
if err := s.credentialRepo.RevokeOwnedPasskey(ctx, userID, credID); err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return model.NewNotFoundError("passkey not found", model.ErrNotFound)
|
||||
}
|
||||
return model.NewInternalError("delete credential", err)
|
||||
}
|
||||
return nil
|
||||
@@ -296,14 +285,12 @@ func (s *AuthService) issueTokens(ctx context.Context, userID string) (*TokenPai
|
||||
return nil, model.NewInternalError("generate refresh token", err)
|
||||
}
|
||||
|
||||
session := &model.Session{
|
||||
if err := s.sessionRepo.CreateRefreshSession(ctx, repository.RefreshSessionParams{
|
||||
ID: uuid.NewString(),
|
||||
UserID: userID,
|
||||
TokenHash: auth.HashToken(refreshToken),
|
||||
ExpiresAt: time.Now().Add(s.refreshTTL),
|
||||
}
|
||||
|
||||
if err := s.sessionRepo.Create(ctx, session); err != nil {
|
||||
}); err != nil {
|
||||
return nil, model.NewInternalError("create session", err)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user