Add monorepo structure for NexaVPN WireGuard control plane including: - .gitignore for node_modules, build artifacts, and environment files - README with project overview, monorepo layout, and quick start guide - Admin web UI with React, Vite, TypeScript, and nginx reverse proxy - API client with type definitions for users, devices, policies, gateways, and audit logs - Admin pages for dashboard, users, devices, policies, g
156 lines
4.2 KiB
Go
156 lines
4.2 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
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, 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, 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)
|
|
}
|