Files
mygo/internal/service/auth.go
T

272 lines
8.4 KiB
Go

package service
import (
"context"
"errors"
"net/http"
"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 `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
// 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"`
}
// 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.AppError{Status: http.StatusBadRequest, Message: "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.AppError{Status: http.StatusConflict, Message: "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.AppError{Status: http.StatusUnauthorized, Message: "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"}
}
if err := auth.VerifyPassword(user.PasswordHash, password); err != nil {
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "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.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
}
if claims.Type != auth.TokenRefresh {
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "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.NewInternalError("find session", err)
}
if session.UserID != claims.UserID {
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
}
user, err := s.userRepo.FindByID(ctx, claims.UserID)
if err != nil {
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "invalid token"}
}
if user.Status != model.StatusActive {
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "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.AppError{Status: http.StatusUnauthorized, Message: "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.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.AppError{Status: http.StatusUnauthorized, Message: "invalid passkey"}
}
if cred.Type != "app_passkey" {
return nil, &model.AppError{Status: http.StatusUnauthorized, Message: "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.AppError{Status: http.StatusNotFound, Message: "passkey not found", Err: 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 s.credentialRepo.Delete(ctx, credID)
}
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
}