diff --git a/internal/auth/service.go b/internal/auth/service.go new file mode 100644 index 0000000..93dc043 --- /dev/null +++ b/internal/auth/service.go @@ -0,0 +1,246 @@ +package auth + +import ( + "fmt" + "net/http" + "strings" + + "git.oblat.lv/alex/triggerssmith/internal/config" + "git.oblat.lv/alex/triggerssmith/internal/jwt" + "git.oblat.lv/alex/triggerssmith/internal/token" + "git.oblat.lv/alex/triggerssmith/internal/user" + ejwt "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +type Tokens struct { + Access string + Refresh string +} + +type Service struct { + cfg *config.Config + + services struct { + jwt *jwt.Service + user *user.Service + token *token.Service + } +} + +type AuthServiceDependencies struct { + Configuration *config.Config + + JWTService *jwt.Service + UserService *user.Service + TokenService *token.Service +} + +func NewAuthService(deps AuthServiceDependencies) (*Service, error) { + if deps.Configuration == nil { + return nil, fmt.Errorf("config is nil") + } + if deps.JWTService == nil { + return nil, fmt.Errorf("jwt service is nil") + } + if deps.UserService == nil { + return nil, fmt.Errorf("user service is nil") + } + if deps.TokenService == nil { + return nil, fmt.Errorf("token service is nil") + } + return &Service{ + cfg: deps.Configuration, + services: struct { + jwt *jwt.Service + user *user.Service + token *token.Service + }{ + jwt: deps.JWTService, + user: deps.UserService, + token: deps.TokenService, + }, + }, nil +} + +// Users + +func (s *Service) Get(by, value string) (*user.User, error) { + return s.services.user.GetBy(by, value) +} + +// Register creates a new user with the given username, email, and password. +// Password is hashed before storing. +// Returns the created user or an error. +func (s *Service) Register(username, email, password string) (*user.User, error) { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("failed to hash password: %w", err) + } + + user := &user.User{ + Username: username, + Email: email, + Password: string(hashedPassword), + } + + err = s.services.user.Create(user) + if err != nil { + return nil, fmt.Errorf("failed to create user: %w", err) + } + + return user, nil +} + +// Login authenticates a user with the given username and password. +// Returns access and refresh tokens if successful. +func (s *Service) Login(username, password string) (*Tokens, error) { + user, err := s.services.user.GetBy("username", username) + if err != nil { + return nil, fmt.Errorf("failed to get user by username: %w", err) + } + + err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) + if err != nil { + return nil, fmt.Errorf("invalid password: %w", err) + } + refreshToken, rjti, err := s.services.jwt.Generate(s.cfg.Auth.RefreshTokenTTL, ejwt.MapClaims{ + "sub": user.ID, + }) + if err != nil { + return nil, fmt.Errorf("failed to generate refresh token: %w", err) + } + accessToken, _, err := s.services.jwt.Generate(s.cfg.Auth.AccessTokenTTL, ejwt.MapClaims{ + "sub": user.ID, + "rjti": rjti, + }) + if err != nil { + return nil, fmt.Errorf("failed to generate refresh token: %w", err) + } + return &Tokens{Access: accessToken, Refresh: refreshToken}, nil +} + +// Logout revokes the refresh token identified by the given rjti. +func (s *Service) Logout(rjti string) error { + return s.services.token.RevokeByRefreshDefault(rjti) +} + +// Access tokens + +// ValidateAccessToken validates the given access token string. +// Returns the user ID (sub claim) if valid, or an error. +func (s *Service) ValidateAccessToken(tokenStr string) (int64, error) { + claims, _, err := s.services.jwt.Validate(tokenStr) + if err != nil { + return 0, fmt.Errorf("failed to validate access token: %w", err) + } + + isRevoked, err := s.services.token.IsRevoked(claims["rjti"].(string)) + if err != nil { + return 0, fmt.Errorf("failed to check if token is revoked: %w", err) + } + if isRevoked { + return 0, fmt.Errorf("token is revoked") + } + + sub := claims["sub"].(float64) + return int64(sub), nil +} + +// Refresh tokens + +// RefreshTokens validates the given refresh token and issues new access and refresh tokens. +// Returns the new access and refresh tokens or an error. +func (s *Service) RefreshTokens(refreshTokenStr string) (*Tokens, error) { + claims, rjti, err := s.services.jwt.Validate(refreshTokenStr) + if err != nil { + return nil, fmt.Errorf("failed to validate refresh token: %w", err) + } + + isRevoked, err := s.services.token.IsRevoked(rjti) + if err != nil { + return nil, fmt.Errorf("failed to check if token is revoked: %w", err) + } + if isRevoked { + return nil, fmt.Errorf("refresh token is revoked") + } + + sub := claims["sub"].(float64) + + newRefreshToken, newRjti, err := s.services.jwt.Generate(s.cfg.Auth.RefreshTokenTTL, ejwt.MapClaims{ + "sub": sub, + }) + if err != nil { + return nil, fmt.Errorf("failed to generate new refresh token: %w", err) + } + newAccessToken, _, err := s.services.jwt.Generate(s.cfg.Auth.AccessTokenTTL, ejwt.MapClaims{ + "sub": sub, + "rjti": newRjti, + }) + if err != nil { + return nil, fmt.Errorf("failed to generate new access token: %w", err) + } + + // Revoke the old refresh token + if err := s.services.token.RevokeByRefreshDefault(rjti); err != nil { + return nil, fmt.Errorf("failed to revoke old refresh token: %w", err) + } + + return &Tokens{Access: newAccessToken, Refresh: newRefreshToken}, nil +} + +// ValidateRefreshToken validates the given refresh token string. +// Returns user id and error. +func (s *Service) ValidateRefreshToken(tokenStr string) (int64, error) { + claims, _, err := s.services.jwt.Validate(tokenStr) + if err != nil { + return 0, fmt.Errorf("failed to validate refresh token: %w", err) + } + + isRevoked, err := s.services.token.IsRevoked(claims["jti"].(string)) + if err != nil { + return 0, fmt.Errorf("failed to check if token is revoked: %w", err) + } + if isRevoked { + return 0, fmt.Errorf("refresh token is revoked") + } + + sub := claims["sub"].(float64) + return int64(sub), nil +} + +// RevokeRefresh revokes the refresh token identified by the given token string. +func (s *Service) RevokeRefresh(token string) error { + _, rjti, err := s.services.jwt.Validate(token) + if err != nil { + return fmt.Errorf("failed to validate refresh token: %w", err) + } + + return s.services.token.RevokeByRefreshDefault(rjti) +} + +// IsRefreshRevoked checks if the refresh token identified by the given token string is revoked. +func (s *Service) IsRefreshRevoked(token string) (bool, error) { + _, rjti, err := s.services.jwt.Validate(token) + if err != nil { + return false, fmt.Errorf("failed to validate refresh token: %w", err) + } + + return s.services.token.IsRevoked(rjti) +} + +func (s *Service) AuthenticateRequest(r *http.Request) (ejwt.Claims, error) { + header := r.Header.Get("Authorization") + if header == "" { + return nil, fmt.Errorf("token is missing") + } + if !strings.HasPrefix(header, "Bearer ") { + return nil, fmt.Errorf("token is missing") + } + tokenString := strings.TrimPrefix(header, "Bearer ") + tokenClaims, _, err := s.services.jwt.Validate(tokenString) + if err != nil { + return nil, err + } + return tokenClaims, nil +}