chore: initial project scaffold with admin web, backend, desktop client, and deployment setup
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
This commit is contained in:
155
backend/internal/auth/service.go
Normal file
155
backend/internal/auth/service.go
Normal file
@@ -0,0 +1,155 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user