63ede5c237
- 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.
305 lines
8.8 KiB
Go
305 lines
8.8 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/dhao2001/mygo/internal/auth"
|
|
"github.com/dhao2001/mygo/internal/model"
|
|
"github.com/dhao2001/mygo/internal/repository"
|
|
)
|
|
|
|
// TokenPair contains the access and refresh tokens returned after authentication.
|
|
type TokenPair struct {
|
|
AccessToken string
|
|
RefreshToken string
|
|
}
|
|
|
|
// CreatedPasskey contains the raw token for a newly created app passkey.
|
|
type CreatedPasskey struct {
|
|
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.
|
|
type AuthService struct {
|
|
userRepo repository.UserRepository
|
|
sessionRepo repository.SessionRepository
|
|
credentialRepo repository.CredentialRepository
|
|
jwtSecret []byte
|
|
accessTTL time.Duration
|
|
refreshTTL time.Duration
|
|
}
|
|
|
|
// NewAuthService creates an AuthService.
|
|
func NewAuthService(
|
|
userRepo repository.UserRepository,
|
|
sessionRepo repository.SessionRepository,
|
|
credentialRepo repository.CredentialRepository,
|
|
jwtSecret []byte,
|
|
accessTTL time.Duration,
|
|
refreshTTL time.Duration,
|
|
) *AuthService {
|
|
return &AuthService{
|
|
userRepo: userRepo,
|
|
sessionRepo: sessionRepo,
|
|
credentialRepo: credentialRepo,
|
|
jwtSecret: jwtSecret,
|
|
accessTTL: accessTTL,
|
|
refreshTTL: refreshTTL,
|
|
}
|
|
}
|
|
|
|
// 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.NewInvalidArgumentError("username, email, and password are required")
|
|
}
|
|
|
|
passwordHash, err := auth.HashPassword(password)
|
|
if err != nil {
|
|
return nil, model.NewInternalError("hash password", err)
|
|
}
|
|
|
|
user := &model.User{
|
|
ID: uuid.NewString(),
|
|
Username: username,
|
|
Email: email,
|
|
PasswordHash: passwordHash,
|
|
Status: model.StatusActive,
|
|
}
|
|
|
|
if err := s.userRepo.Create(ctx, user); err != nil {
|
|
if errors.Is(err, model.ErrDuplicate) {
|
|
return nil, model.NewConflictError("username or email already exists")
|
|
}
|
|
return nil, model.NewInternalError("create user", err)
|
|
}
|
|
|
|
return user, nil
|
|
}
|
|
|
|
// Login authenticates a user by email and password, returning a token pair.
|
|
func (s *AuthService) Login(ctx context.Context, email, password string) (*TokenPair, error) {
|
|
user, err := s.userRepo.FindByEmail(ctx, email)
|
|
if err != nil {
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
return nil, model.NewUnauthenticatedError("invalid email or password")
|
|
}
|
|
return nil, model.NewInternalError("find user", err)
|
|
}
|
|
|
|
if user.Status != model.StatusActive {
|
|
return nil, model.NewUnauthenticatedError("invalid email or password")
|
|
}
|
|
|
|
if err := auth.VerifyPassword(user.PasswordHash, password); err != nil {
|
|
return nil, model.NewUnauthenticatedError("invalid email or password")
|
|
}
|
|
|
|
return s.issueTokens(ctx, user.ID)
|
|
}
|
|
|
|
// Refresh validates a refresh token and returns a new token pair.
|
|
// Each refresh token is single-use: the old session is deleted.
|
|
func (s *AuthService) Refresh(ctx context.Context, refreshTokenStr string) (*TokenPair, error) {
|
|
claims, err := auth.ParseToken(refreshTokenStr, s.jwtSecret)
|
|
if err != nil {
|
|
return nil, model.NewUnauthenticatedError("invalid token")
|
|
}
|
|
|
|
if claims.Type != auth.TokenRefresh {
|
|
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.NewUnauthenticatedError("invalid token")
|
|
}
|
|
return nil, model.NewInternalError("find session", err)
|
|
}
|
|
|
|
if session.UserID != claims.UserID {
|
|
return nil, model.NewUnauthenticatedError("invalid token")
|
|
}
|
|
|
|
user, err := s.userRepo.FindByID(ctx, claims.UserID)
|
|
if err != nil {
|
|
return nil, model.NewUnauthenticatedError("invalid token")
|
|
}
|
|
if user.Status != model.StatusActive {
|
|
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)
|
|
}
|
|
|
|
return s.sessionRepo.Delete(ctx, session.ID)
|
|
}
|
|
|
|
// CreatePasskey creates a new app passkey for the authenticated user.
|
|
func (s *AuthService) CreatePasskey(ctx context.Context, userID, label string) (*CreatedPasskey, error) {
|
|
raw, hash, err := auth.GenerateToken()
|
|
if err != nil {
|
|
return nil, model.NewInternalError("generate token", err)
|
|
}
|
|
|
|
cred := &model.Credential{
|
|
ID: uuid.NewString(),
|
|
UserID: userID,
|
|
Type: "app_passkey",
|
|
Label: label,
|
|
SecretHash: hash,
|
|
}
|
|
|
|
if err := s.credentialRepo.Create(ctx, cred); err != nil {
|
|
return nil, model.NewInternalError("create credential", err)
|
|
}
|
|
|
|
return &CreatedPasskey{
|
|
ID: cred.ID,
|
|
Raw: raw,
|
|
Label: label,
|
|
}, nil
|
|
}
|
|
|
|
// 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.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.NewUnauthenticatedError("invalid passkey")
|
|
}
|
|
return nil, model.NewInternalError("find credential", err)
|
|
}
|
|
|
|
user, err := s.userRepo.FindByIDIncludeDeleted(ctx, cred.UserID)
|
|
if err != nil {
|
|
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 {
|
|
return nil, model.NewInternalError("update last used", err)
|
|
}
|
|
|
|
return s.issueTokens(ctx, cred.UserID)
|
|
}
|
|
|
|
// ListPasskeys returns all app passkeys for a user.
|
|
func (s *AuthService) ListPasskeys(ctx context.Context, userID string) ([]model.Credential, error) {
|
|
return s.credentialRepo.FindByUserIDAndType(ctx, userID, "app_passkey")
|
|
}
|
|
|
|
// 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)
|
|
if err != nil {
|
|
if errors.Is(err, model.ErrNotFound) {
|
|
return model.NewNotFoundError("passkey not found", model.ErrNotFound)
|
|
}
|
|
return model.NewInternalError("find credential", err)
|
|
}
|
|
|
|
if cred.UserID != userID {
|
|
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 {
|
|
return nil, model.NewInternalError("generate access token", err)
|
|
}
|
|
|
|
refreshToken, err := auth.GenerateRefreshToken(userID, s.jwtSecret, s.refreshTTL)
|
|
if err != nil {
|
|
return nil, model.NewInternalError("generate refresh token", err)
|
|
}
|
|
|
|
session := &model.Session{
|
|
ID: uuid.NewString(),
|
|
UserID: userID,
|
|
TokenHash: auth.HashToken(refreshToken),
|
|
ExpiresAt: time.Now().Add(s.refreshTTL),
|
|
}
|
|
|
|
if err := s.sessionRepo.Create(ctx, session); err != nil {
|
|
return nil, model.NewInternalError("create session", err)
|
|
}
|
|
|
|
return &TokenPair{
|
|
AccessToken: accessToken,
|
|
RefreshToken: refreshToken,
|
|
}, nil
|
|
}
|