Files
NexaVPN/backend/internal/auth/service.go
nessi 09dd3a5ea6 feat: add bootstrap availability check to login page with conditional UI
Add useEffect hook to fetch bootstrap status on component mount. Add bootstrapAvailable and bootstrapStatusLoaded state variables to track bootstrap endpoint availability. Hide mode toggle button when bootstrap is unavailable or status hasn't loaded yet. Add auth-brand and auth-brand-copy CSS classes to improve login page layout and branding. Add BootstrapStatus handler and BootstrapAvailable service method to expose bootstrap availability
2026-03-17 19:59:15 +01:00

167 lines
4.5 KiB
Go

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) BootstrapAvailable(ctx context.Context) (bool, error) {
hasUsers, err := s.repo.HasUsers(ctx)
if err != nil {
return false, err
}
return !hasUsers, nil
}
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)
}