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
167 lines
4.5 KiB
Go
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)
|
|
}
|