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:
2026-07-05 23:30:17 +08:00
parent 28e17a5b08
commit 63ede5c237
34 changed files with 1028 additions and 438 deletions
+56 -23
View File
@@ -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 {