package auth import ( "context" "errors" "time" "github.com/google/uuid" "nexavpn/backend/internal/identity" ) var ErrInvalidCredentials = errors.New("invalid credentials") type UserRecord struct { ID uuid.UUID Username string DisplayName string Role string PasswordHash string IsActive bool } type Repository interface { FindUserByUsername(ctx context.Context, username string) (UserRecord, error) CreateSession(ctx context.Context, userID uuid.UUID, expiresAt time.Time, ipAddress string, userAgent string) (uuid.UUID, error) StoreRefreshToken(ctx context.Context, sessionID uuid.UUID, userID uuid.UUID, tokenHash string, expiresAt time.Time) error FindRefreshToken(ctx context.Context, tokenHash string) (UserRecord, uuid.UUID, error) RevokeRefreshToken(ctx context.Context, tokenHash string) error HasUsers(ctx context.Context) (bool, error) CreateBootstrapAdmin(ctx context.Context, username, displayName, passwordHash string) (UserRecord, error) } type Service struct { repo Repository jwtSecret string jwtIssuer string accessTokenTTL time.Duration refreshTokenTTL time.Duration } func NewService(repo Repository, jwtSecret, jwtIssuer string, accessTokenTTL, refreshTokenTTL time.Duration) *Service { return &Service{ repo: repo, jwtSecret: jwtSecret, jwtIssuer: jwtIssuer, accessTokenTTL: accessTokenTTL, refreshTokenTTL: refreshTokenTTL, } } func (s *Service) Login(ctx context.Context, username, password, ipAddress, userAgent string) (LoginResponse, error) { record, err := s.repo.FindUserByUsername(ctx, username) if err != nil || !record.IsActive || !VerifyPassword(record.PasswordHash, password) { return LoginResponse{}, ErrInvalidCredentials } sessionID, err := s.repo.CreateSession(ctx, record.ID, time.Now().Add(s.refreshTokenTTL), ipAddress, userAgent) if err != nil { return LoginResponse{}, err } plainRefresh, hashedRefresh, err := NewRefreshToken() if err != nil { return LoginResponse{}, err } if err := s.repo.StoreRefreshToken(ctx, sessionID, record.ID, hashedRefresh, time.Now().Add(s.refreshTokenTTL)); err != nil { return LoginResponse{}, err } access, err := SignAccessToken(s.jwtSecret, s.jwtIssuer, s.accessTokenTTL, identity.Claims{ UserID: record.ID, Username: record.Username, Role: record.Role, Session: sessionID, }) if err != nil { return LoginResponse{}, err } return LoginResponse{ AccessToken: access, RefreshToken: plainRefresh, ExpiresIn: int64(s.accessTokenTTL.Seconds()), User: UserView{ ID: record.ID, Username: record.Username, DisplayName: record.DisplayName, Role: record.Role, }, }, nil } func (s *Service) Refresh(ctx context.Context, refreshToken string) (LoginResponse, error) { record, sessionID, err := s.repo.FindRefreshToken(ctx, hashToken(refreshToken)) if err != nil { return LoginResponse{}, ErrInvalidCredentials } access, err := SignAccessToken(s.jwtSecret, s.jwtIssuer, s.accessTokenTTL, identity.Claims{ UserID: record.ID, Username: record.Username, Role: record.Role, Session: sessionID, }) if err != nil { return LoginResponse{}, err } return LoginResponse{ AccessToken: access, RefreshToken: refreshToken, ExpiresIn: int64(s.accessTokenTTL.Seconds()), User: UserView{ ID: record.ID, Username: record.Username, DisplayName: record.DisplayName, Role: record.Role, }, }, nil } func (s *Service) Logout(ctx context.Context, refreshToken string) error { return s.repo.RevokeRefreshToken(ctx, hashToken(refreshToken)) } func (s *Service) BootstrapAdmin(ctx context.Context, username, displayName, password string) (UserView, error) { hasUsers, err := s.repo.HasUsers(ctx) if err != nil { return UserView{}, err } if hasUsers { return UserView{}, errors.New("bootstrap already completed") } passwordHash, err := HashPassword(password) if err != nil { return UserView{}, err } record, err := s.repo.CreateBootstrapAdmin(ctx, username, displayName, passwordHash) if err != nil { return UserView{}, err } return UserView{ ID: record.ID, Username: record.Username, DisplayName: record.DisplayName, Role: record.Role, }, nil } func hashToken(plain string) string { return base64Hash(plain) }