refactor(api): enforce protocol-neutral service boundaries
- refactor: move HTTP status and DTO concerns out of model and service layers into API and handler code. - feat: add admin service boundary and route auth through services instead of direct repository access. - test: add architecture and error tests covering package boundaries, redaction, and service behavior. - docs: record the protocol-neutral boundary decision and update architecture and roadmap notes.
This commit is contained in:
+56
-23
@@ -3,7 +3,6 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -16,15 +15,21 @@ import (
|
||||
|
||||
// TokenPair contains the access and refresh tokens returned after authentication.
|
||||
type TokenPair struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
}
|
||||
|
||||
// CreatedPasskey contains the raw token for a newly created app passkey.
|
||||
type CreatedPasskey struct {
|
||||
ID string `json:"id"`
|
||||
Raw string `json:"raw"`
|
||||
Label string `json:"label"`
|
||||
ID string
|
||||
Raw string
|
||||
Label string
|
||||
}
|
||||
|
||||
// Principal is the authenticated user identity used by transport layers.
|
||||
type Principal struct {
|
||||
UserID string
|
||||
IsAdmin bool
|
||||
}
|
||||
|
||||
// AuthService handles user authentication and session management.
|
||||
@@ -59,7 +64,7 @@ func NewAuthService(
|
||||
// Register creates a new user account.
|
||||
func (s *AuthService) Register(ctx context.Context, username, email, password string) (*model.User, error) {
|
||||
if username == "" || email == "" || password == "" {
|
||||
return nil, &model.AppError{Status: http.StatusBadRequest, Message: "username, email, and password are required"}
|
||||
return nil, model.NewInvalidArgumentError("username, email, and password are required")
|
||||
}
|
||||
|
||||
passwordHash, err := auth.HashPassword(password)
|
||||
@@ -77,7 +82,7 @@ func (s *AuthService) Register(ctx context.Context, username, email, password st
|
||||
|
||||
if err := s.userRepo.Create(ctx, user); err != nil {
|
||||
if errors.Is(err, model.ErrDuplicate) {
|
||||
return nil, &model.AppError{Status: http.StatusConflict, Message: "username or email already exists"}
|
||||
return nil, model.NewConflictError("username or email already exists")
|
||||
}
|
||||
return nil, model.NewInternalError("create user", err)
|
||||
}
|
||||
@@ -90,17 +95,17 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (*Token
|
||||
user, err := s.userRepo.FindByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid email or password"}
|
||||
return nil, model.NewUnauthenticatedError("invalid email or password")
|
||||
}
|
||||
return nil, model.NewInternalError("find user", err)
|
||||
}
|
||||
|
||||
if user.Status != model.StatusActive {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid email or password"}
|
||||
return nil, model.NewUnauthenticatedError("invalid email or password")
|
||||
}
|
||||
|
||||
if err := auth.VerifyPassword(user.PasswordHash, password); err != nil {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid email or password"}
|
||||
return nil, model.NewUnauthenticatedError("invalid email or password")
|
||||
}
|
||||
|
||||
return s.issueTokens(ctx, user.ID)
|
||||
@@ -111,32 +116,32 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (*Token
|
||||
func (s *AuthService) Refresh(ctx context.Context, refreshTokenStr string) (*TokenPair, error) {
|
||||
claims, err := auth.ParseToken(refreshTokenStr, s.jwtSecret)
|
||||
if err != nil {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
|
||||
return nil, model.NewUnauthenticatedError("invalid token")
|
||||
}
|
||||
|
||||
if claims.Type != auth.TokenRefresh {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
|
||||
return nil, model.NewUnauthenticatedError("invalid token")
|
||||
}
|
||||
|
||||
tokenHash := auth.HashToken(refreshTokenStr)
|
||||
session, err := s.sessionRepo.FindByTokenHash(ctx, tokenHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
|
||||
return nil, model.NewUnauthenticatedError("invalid token")
|
||||
}
|
||||
return nil, model.NewInternalError("find session", err)
|
||||
}
|
||||
|
||||
if session.UserID != claims.UserID {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
|
||||
return nil, model.NewUnauthenticatedError("invalid token")
|
||||
}
|
||||
|
||||
user, err := s.userRepo.FindByID(ctx, claims.UserID)
|
||||
if err != nil {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
|
||||
return nil, model.NewUnauthenticatedError("invalid token")
|
||||
}
|
||||
if user.Status != model.StatusActive {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
|
||||
return nil, model.NewUnauthenticatedError("invalid token")
|
||||
}
|
||||
|
||||
if err := s.sessionRepo.Delete(ctx, session.ID); err != nil {
|
||||
@@ -189,14 +194,14 @@ func (s *AuthService) CreatePasskey(ctx context.Context, userID, label string) (
|
||||
// LoginWithPasskey authenticates a user using an app passkey token.
|
||||
func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*TokenPair, error) {
|
||||
if !strings.HasPrefix(tokenStr, "mygo_") {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid passkey format"}
|
||||
return nil, model.NewUnauthenticatedError("invalid passkey format")
|
||||
}
|
||||
|
||||
tokenHash := auth.HashToken(tokenStr)
|
||||
cred, err := s.credentialRepo.FindByHash(ctx, tokenHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid passkey"}
|
||||
return nil, model.NewUnauthenticatedError("invalid passkey")
|
||||
}
|
||||
return nil, model.NewInternalError("find credential", err)
|
||||
}
|
||||
@@ -206,11 +211,11 @@ func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*T
|
||||
return nil, model.NewInternalError("find user", err)
|
||||
}
|
||||
if user.Status != model.StatusActive {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid passkey"}
|
||||
return nil, model.NewUnauthenticatedError("invalid passkey")
|
||||
}
|
||||
|
||||
if cred.Type != "app_passkey" {
|
||||
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid credential type"}
|
||||
return nil, model.NewUnauthenticatedError("invalid credential type")
|
||||
}
|
||||
|
||||
if err := s.credentialRepo.UpdateLastUsed(ctx, cred.ID); err != nil {
|
||||
@@ -230,18 +235,46 @@ func (s *AuthService) RevokePasskey(ctx context.Context, userID, credID string)
|
||||
cred, err := s.credentialRepo.FindByID(ctx, credID)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return &model.AppError{Status: http.StatusNotFound, Message: "passkey not found", Err: model.ErrNotFound}
|
||||
return model.NewNotFoundError("passkey not found", model.ErrNotFound)
|
||||
}
|
||||
return model.NewInternalError("find credential", err)
|
||||
}
|
||||
|
||||
if cred.UserID != userID {
|
||||
return &model.AppError{Status: http.StatusForbidden, Message: "access denied", Err: model.ErrForbidden}
|
||||
return model.NewPermissionDeniedError("access denied", model.ErrForbidden)
|
||||
}
|
||||
|
||||
return s.credentialRepo.Delete(ctx, credID)
|
||||
}
|
||||
|
||||
// AuthenticateAccessToken validates an access token and returns the active user principal.
|
||||
func (s *AuthService) AuthenticateAccessToken(ctx context.Context, accessTokenStr string) (*Principal, error) {
|
||||
claims, err := auth.ParseToken(accessTokenStr, s.jwtSecret)
|
||||
if err != nil {
|
||||
return nil, model.NewUnauthenticatedError("invalid or expired token")
|
||||
}
|
||||
|
||||
if claims.Type != auth.TokenAccess {
|
||||
return nil, model.NewUnauthenticatedError("invalid token type")
|
||||
}
|
||||
|
||||
user, err := s.userRepo.FindByID(ctx, claims.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, model.NewUnauthenticatedError("user not found")
|
||||
}
|
||||
return nil, model.NewInternalError("find access token user", err)
|
||||
}
|
||||
if user.Status != model.StatusActive {
|
||||
return nil, model.NewUnauthenticatedError("user not found")
|
||||
}
|
||||
|
||||
return &Principal{
|
||||
UserID: user.ID,
|
||||
IsAdmin: user.IsAdmin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) issueTokens(ctx context.Context, userID string) (*TokenPair, error) {
|
||||
accessToken, err := auth.GenerateAccessToken(userID, s.jwtSecret, s.accessTTL)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user