This commit is contained in:
2025-09-01 18:26:44 -04:00
parent 74b2d75dbc
commit 3e3ade75e5
31 changed files with 1678 additions and 191 deletions

1
kms/web/.gitignore vendored
View File

@ -1 +1,2 @@
dist
node_modules

1
kms/web/dist/211.js vendored

File diff suppressed because one or more lines are too long

2
kms/web/dist/265.js vendored

File diff suppressed because one or more lines are too long

View File

@ -1,21 +0,0 @@
/**
* @remix-run/router v1.23.0
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/
/**
* React Router v6.30.1
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/

1
kms/web/dist/396.js vendored

File diff suppressed because one or more lines are too long

2
kms/web/dist/540.js vendored

File diff suppressed because one or more lines are too long

View File

@ -1,9 +0,0 @@
/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

2
kms/web/dist/63.js vendored

File diff suppressed because one or more lines are too long

View File

@ -1,9 +0,0 @@
/**
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

1
kms/web/dist/665.js vendored

File diff suppressed because one or more lines are too long

1
kms/web/dist/870.js vendored

File diff suppressed because one or more lines are too long

2
kms/web/dist/875.js vendored

File diff suppressed because one or more lines are too long

View File

@ -1,9 +0,0 @@
/**
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

2
kms/web/dist/961.js vendored

File diff suppressed because one or more lines are too long

View File

@ -1,19 +0,0 @@
/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

View File

@ -1 +0,0 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="KMS - Key Management System"/><title>KMS</title><script defer="defer" src="main.js"></script><script defer="defer" src="remoteEntry.js"></script></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>

File diff suppressed because one or more lines are too long

View File

@ -1,31 +0,0 @@
/**
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @remix-run/router v1.23.0
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/
/**
* React Router v6.30.1
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/

File diff suppressed because one or more lines are too long

View File

@ -4,11 +4,14 @@ go 1.23
require (
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/jmoiron/sqlx v1.4.0
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/pquerna/otp v1.4.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.23.0
)
require (

View File

@ -37,6 +37,22 @@ type User struct {
Role UserRole `json:"role" validate:"required,oneof=admin user moderator viewer" db:"role"`
Status UserStatus `json:"status" validate:"required,oneof=active inactive suspended pending" db:"status"`
LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"`
// Security fields
PasswordHash string `json:"-" db:"password_hash"` // Hidden from JSON
PasswordSalt string `json:"-" db:"password_salt"` // Hidden from JSON
EmailVerified bool `json:"email_verified" db:"email_verified"`
EmailVerificationToken *string `json:"-" db:"email_verification_token"` // Hidden from JSON
EmailVerificationExpiresAt *time.Time `json:"-" db:"email_verification_expires_at"` // Hidden from JSON
PasswordResetToken *string `json:"-" db:"password_reset_token"` // Hidden from JSON
PasswordResetExpiresAt *time.Time `json:"-" db:"password_reset_expires_at"` // Hidden from JSON
FailedLoginAttempts int `json:"-" db:"failed_login_attempts"` // Hidden from JSON
LockedUntil *time.Time `json:"-" db:"locked_until"` // Hidden from JSON
TwoFactorEnabled bool `json:"two_factor_enabled" db:"two_factor_enabled"`
TwoFactorSecret *string `json:"-" db:"two_factor_secret"` // Hidden from JSON
TwoFactorBackupCodes []string `json:"-" db:"two_factor_backup_codes"` // Hidden from JSON
LastPasswordChange *time.Time `json:"last_password_change,omitempty" db:"last_password_change"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
CreatedBy string `json:"created_by" validate:"required" db:"created_by"`
@ -70,13 +86,15 @@ type UserSession struct {
// CreateUserRequest represents a request to create a new user
type CreateUserRequest struct {
Email string `json:"email" validate:"required,email,max=255"`
FirstName string `json:"first_name" validate:"required,min=1,max=100"`
LastName string `json:"last_name" validate:"required,min=1,max=100"`
DisplayName *string `json:"display_name,omitempty" validate:"omitempty,max=200"`
Avatar *string `json:"avatar,omitempty" validate:"omitempty,url,max=500"`
Role UserRole `json:"role" validate:"required,oneof=admin user moderator viewer"`
Status UserStatus `json:"status" validate:"omitempty,oneof=active inactive suspended pending"`
Email string `json:"email" validate:"required,email,max=255"`
FirstName string `json:"first_name" validate:"required,min=1,max=100"`
LastName string `json:"last_name" validate:"required,min=1,max=100"`
DisplayName *string `json:"display_name,omitempty" validate:"omitempty,max=200"`
Avatar *string `json:"avatar,omitempty" validate:"omitempty,url,max=500"`
Role UserRole `json:"role" validate:"required,oneof=admin user moderator viewer"`
Status UserStatus `json:"status" validate:"omitempty,oneof=active inactive suspended pending"`
Password *string `json:"password,omitempty" validate:"omitempty,min=8,max=128"`
SendWelcomeEmail bool `json:"send_welcome_email" validate:"omitempty"`
}
// UpdateUserRequest represents a request to update an existing user
@ -120,6 +138,52 @@ type ListUsersResponse struct {
HasMore bool `json:"has_more"`
}
// PasswordResetToken represents a password reset token
type PasswordResetToken struct {
ID uuid.UUID `json:"id" db:"id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
Token string `json:"-" db:"token"` // Hidden from JSON
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
UsedAt *time.Time `json:"used_at,omitempty" db:"used_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
IPAddress *string `json:"ip_address,omitempty" db:"ip_address"`
UserAgent *string `json:"user_agent,omitempty" db:"user_agent"`
}
// EmailVerificationToken represents an email verification token
type EmailVerificationToken struct {
ID uuid.UUID `json:"id" db:"id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
Token string `json:"-" db:"token"` // Hidden from JSON
Email string `json:"email" db:"email"`
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
UsedAt *time.Time `json:"used_at,omitempty" db:"used_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
IPAddress *string `json:"ip_address,omitempty" db:"ip_address"`
UserAgent *string `json:"user_agent,omitempty" db:"user_agent"`
}
// LoginAttempt represents a login attempt record
type LoginAttempt struct {
ID uuid.UUID `json:"id" db:"id"`
Email string `json:"email" db:"email"`
IPAddress string `json:"ip_address" db:"ip_address"`
UserAgent *string `json:"user_agent,omitempty" db:"user_agent"`
Success bool `json:"success" db:"success"`
FailureReason *string `json:"failure_reason,omitempty" db:"failure_reason"`
AttemptedAt time.Time `json:"attempted_at" db:"attempted_at"`
SessionID *string `json:"session_id,omitempty" db:"session_id"`
}
// TwoFactorRecoveryCode represents a 2FA recovery code
type TwoFactorRecoveryCode struct {
ID uuid.UUID `json:"id" db:"id"`
UserID uuid.UUID `json:"user_id" db:"user_id"`
CodeHash string `json:"-" db:"code_hash"` // Hidden from JSON
UsedAt *time.Time `json:"used_at,omitempty" db:"used_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// AuthContext represents the authentication context for a request
type AuthContext struct {
UserID string `json:"user_id"`
@ -127,4 +191,105 @@ type AuthContext struct {
Role UserRole `json:"role"`
Permissions []string `json:"permissions"`
Claims map[string]string `json:"claims"`
}
// Authentication Request/Response Types
// LoginRequest represents a login request
type LoginRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
TwoFactorCode *string `json:"two_factor_code,omitempty" validate:"omitempty,len=6"`
RememberMe bool `json:"remember_me"`
}
// LoginResponse represents a login response
type LoginResponse struct {
User *User `json:"user,omitempty"`
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
RequiresTwoFactor bool `json:"requires_two_factor"`
TwoFactorTempToken *string `json:"two_factor_temp_token,omitempty"`
}
// RegisterRequest represents a user registration request
type RegisterRequest struct {
Email string `json:"email" validate:"required,email,max=255"`
Password string `json:"password" validate:"required,min=8,max=128"`
FirstName string `json:"first_name" validate:"required,min=1,max=100"`
LastName string `json:"last_name" validate:"required,min=1,max=100"`
DisplayName *string `json:"display_name,omitempty" validate:"omitempty,max=200"`
}
// RegisterResponse represents a registration response
type RegisterResponse struct {
User *User `json:"user"`
Message string `json:"message"`
}
// ForgotPasswordRequest represents a forgot password request
type ForgotPasswordRequest struct {
Email string `json:"email" validate:"required,email"`
}
// ResetPasswordRequest represents a reset password request
type ResetPasswordRequest struct {
Token string `json:"token" validate:"required"`
Password string `json:"password" validate:"required,min=8,max=128"`
}
// ChangePasswordRequest represents a change password request
type ChangePasswordRequest struct {
CurrentPassword string `json:"current_password" validate:"required"`
NewPassword string `json:"new_password" validate:"required,min=8,max=128"`
}
// VerifyEmailRequest represents an email verification request
type VerifyEmailRequest struct {
Token string `json:"token" validate:"required"`
}
// ResendVerificationRequest represents a resend verification request
type ResendVerificationRequest struct {
Email string `json:"email" validate:"required,email"`
}
// SetupTwoFactorResponse represents the response when setting up 2FA
type SetupTwoFactorResponse struct {
Secret string `json:"secret"`
QRCodeURL string `json:"qr_code_url"`
BackupCodes []string `json:"backup_codes"`
}
// EnableTwoFactorRequest represents a request to enable 2FA
type EnableTwoFactorRequest struct {
Code string `json:"code" validate:"required,len=6"`
}
// DisableTwoFactorRequest represents a request to disable 2FA
type DisableTwoFactorRequest struct {
Password string `json:"password" validate:"required"`
Code *string `json:"code,omitempty" validate:"omitempty,len=6"`
}
// ValidateTwoFactorRequest represents a 2FA validation request
type ValidateTwoFactorRequest struct {
TempToken string `json:"temp_token" validate:"required"`
Code string `json:"code" validate:"required,len=6"`
}
// SessionInfo represents session information
type SessionInfo struct {
ID uuid.UUID `json:"id"`
IPAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt time.Time `json:"last_used_at"`
ExpiresAt time.Time `json:"expires_at"`
IsCurrent bool `json:"is_current"`
}
// ListSessionsResponse represents a list of user sessions
type ListSessionsResponse struct {
Sessions []SessionInfo `json:"sessions"`
}

View File

@ -0,0 +1,545 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/RyanCopley/skybridge/user/internal/domain"
"github.com/RyanCopley/skybridge/user/internal/services"
)
// AuthHandler handles HTTP requests for authentication operations
type AuthHandler struct {
authService services.AuthService
userService services.UserService
logger *zap.Logger
}
// NewAuthHandler creates a new authentication handler
func NewAuthHandler(authService services.AuthService, userService services.UserService, logger *zap.Logger) *AuthHandler {
return &AuthHandler{
authService: authService,
userService: userService,
logger: logger,
}
}
// Register handles POST /auth/register
func (h *AuthHandler) Register(c *gin.Context) {
var req domain.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.logger.Error("Invalid registration request", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid request body",
"details": err.Error(),
})
return
}
response, err := h.authService.Register(c.Request.Context(), &req, getIPAddress(c), c.GetHeader("User-Agent"))
if err != nil {
h.logger.Error("Registration failed", zap.String("email", req.Email), zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{
"error": "Registration failed",
"details": err.Error(),
})
return
}
c.JSON(http.StatusCreated, response)
}
// Login handles POST /auth/login
func (h *AuthHandler) Login(c *gin.Context) {
var req domain.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.logger.Error("Invalid login request", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid request body",
"details": err.Error(),
})
return
}
response, err := h.authService.Login(c.Request.Context(), &req, getIPAddress(c), c.GetHeader("User-Agent"))
if err != nil {
h.logger.Error("Login failed", zap.String("email", req.Email), zap.Error(err))
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authentication failed",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, response)
}
// Logout handles POST /auth/logout
func (h *AuthHandler) Logout(c *gin.Context) {
token := getTokenFromHeader(c)
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Authorization header required",
})
return
}
err := h.authService.Logout(c.Request.Context(), token)
if err != nil {
h.logger.Error("Logout failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Logout failed",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Successfully logged out",
})
}
// RefreshToken handles POST /auth/refresh
func (h *AuthHandler) RefreshToken(c *gin.Context) {
token := getTokenFromHeader(c)
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Authorization header required",
})
return
}
response, err := h.authService.RefreshToken(c.Request.Context(), token)
if err != nil {
h.logger.Error("Token refresh failed", zap.Error(err))
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Token refresh failed",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, response)
}
// ForgotPassword handles POST /auth/forgot-password
func (h *AuthHandler) ForgotPassword(c *gin.Context) {
var req domain.ForgotPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.logger.Error("Invalid forgot password request", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid request body",
"details": err.Error(),
})
return
}
err := h.authService.ForgotPassword(c.Request.Context(), &req, getIPAddress(c), c.GetHeader("User-Agent"))
if err != nil {
h.logger.Error("Forgot password failed", zap.String("email", req.Email), zap.Error(err))
// Don't reveal whether email exists for security
c.JSON(http.StatusOK, gin.H{
"message": "If the email exists, password reset instructions have been sent",
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Password reset instructions have been sent to your email",
})
}
// ResetPassword handles POST /auth/reset-password
func (h *AuthHandler) ResetPassword(c *gin.Context) {
var req domain.ResetPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.logger.Error("Invalid reset password request", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid request body",
"details": err.Error(),
})
return
}
err := h.authService.ResetPassword(c.Request.Context(), &req, getIPAddress(c), c.GetHeader("User-Agent"))
if err != nil {
h.logger.Error("Password reset failed", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{
"error": "Password reset failed",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Password has been successfully reset",
})
}
// ChangePassword handles POST /auth/change-password
func (h *AuthHandler) ChangePassword(c *gin.Context) {
userID := getUserIDFromContext(c)
if userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authentication required",
})
return
}
var req domain.ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.logger.Error("Invalid change password request", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid request body",
"details": err.Error(),
})
return
}
actorID := getActorFromContext(c)
err := h.authService.ChangePassword(c.Request.Context(), userID, &req, actorID)
if err != nil {
h.logger.Error("Change password failed", zap.String("user_id", userID.String()), zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{
"error": "Change password failed",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Password has been successfully changed",
})
}
// VerifyEmail handles POST /auth/verify-email
func (h *AuthHandler) VerifyEmail(c *gin.Context) {
var req domain.VerifyEmailRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.logger.Error("Invalid email verification request", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid request body",
"details": err.Error(),
})
return
}
err := h.authService.VerifyEmail(c.Request.Context(), &req, getIPAddress(c), c.GetHeader("User-Agent"))
if err != nil {
h.logger.Error("Email verification failed", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{
"error": "Email verification failed",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Email has been successfully verified",
})
}
// ResendVerification handles POST /auth/resend-verification
func (h *AuthHandler) ResendVerification(c *gin.Context) {
var req domain.ResendVerificationRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.logger.Error("Invalid resend verification request", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid request body",
"details": err.Error(),
})
return
}
err := h.authService.ResendVerification(c.Request.Context(), &req, getIPAddress(c), c.GetHeader("User-Agent"))
if err != nil {
h.logger.Error("Resend verification failed", zap.String("email", req.Email), zap.Error(err))
// Don't reveal whether email exists
c.JSON(http.StatusOK, gin.H{
"message": "If the email exists and is not verified, verification instructions have been sent",
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Verification email has been sent",
})
}
// Two-Factor Authentication endpoints
// SetupTwoFactor handles GET /auth/2fa/setup
func (h *AuthHandler) SetupTwoFactor(c *gin.Context) {
userID := getUserIDFromContext(c)
if userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authentication required",
})
return
}
response, err := h.authService.SetupTwoFactor(c.Request.Context(), userID)
if err != nil {
h.logger.Error("Setup 2FA failed", zap.String("user_id", userID.String()), zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to setup two-factor authentication",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, response)
}
// EnableTwoFactor handles POST /auth/2fa/enable
func (h *AuthHandler) EnableTwoFactor(c *gin.Context) {
userID := getUserIDFromContext(c)
if userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authentication required",
})
return
}
var req domain.EnableTwoFactorRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.logger.Error("Invalid enable 2FA request", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid request body",
"details": err.Error(),
})
return
}
actorID := getActorFromContext(c)
err := h.authService.EnableTwoFactor(c.Request.Context(), userID, &req, actorID)
if err != nil {
h.logger.Error("Enable 2FA failed", zap.String("user_id", userID.String()), zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{
"error": "Failed to enable two-factor authentication",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Two-factor authentication has been enabled",
})
}
// DisableTwoFactor handles POST /auth/2fa/disable
func (h *AuthHandler) DisableTwoFactor(c *gin.Context) {
userID := getUserIDFromContext(c)
if userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authentication required",
})
return
}
var req domain.DisableTwoFactorRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.logger.Error("Invalid disable 2FA request", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid request body",
"details": err.Error(),
})
return
}
actorID := getActorFromContext(c)
err := h.authService.DisableTwoFactor(c.Request.Context(), userID, &req, actorID)
if err != nil {
h.logger.Error("Disable 2FA failed", zap.String("user_id", userID.String()), zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{
"error": "Failed to disable two-factor authentication",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Two-factor authentication has been disabled",
})
}
// ValidateTwoFactor handles POST /auth/2fa/validate
func (h *AuthHandler) ValidateTwoFactor(c *gin.Context) {
var req domain.ValidateTwoFactorRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.logger.Error("Invalid 2FA validation request", zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid request body",
"details": err.Error(),
})
return
}
response, err := h.authService.ValidateTwoFactor(c.Request.Context(), &req, getIPAddress(c), c.GetHeader("User-Agent"))
if err != nil {
h.logger.Error("2FA validation failed", zap.Error(err))
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Two-factor authentication validation failed",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, response)
}
// RegenerateTwoFactorBackupCodes handles POST /auth/2fa/regenerate-codes
func (h *AuthHandler) RegenerateTwoFactorBackupCodes(c *gin.Context) {
userID := getUserIDFromContext(c)
if userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authentication required",
})
return
}
actorID := getActorFromContext(c)
codes, err := h.authService.RegenerateTwoFactorBackupCodes(c.Request.Context(), userID, actorID)
if err != nil {
h.logger.Error("Regenerate backup codes failed", zap.String("user_id", userID.String()), zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to regenerate backup codes",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"backup_codes": codes,
})
}
// Session Management endpoints
// GetSessions handles GET /auth/sessions
func (h *AuthHandler) GetSessions(c *gin.Context) {
userID := getUserIDFromContext(c)
if userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authentication required",
})
return
}
response, err := h.authService.GetUserSessions(c.Request.Context(), userID)
if err != nil {
h.logger.Error("Get sessions failed", zap.String("user_id", userID.String()), zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to get sessions",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, response)
}
// RevokeSession handles DELETE /auth/sessions/:sessionId
func (h *AuthHandler) RevokeSession(c *gin.Context) {
userID := getUserIDFromContext(c)
if userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authentication required",
})
return
}
sessionIDParam := c.Param("sessionId")
sessionID, err := uuid.Parse(sessionIDParam)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid session ID",
})
return
}
actorID := getActorFromContext(c)
err = h.authService.RevokeSession(c.Request.Context(), userID, sessionID, actorID)
if err != nil {
h.logger.Error("Revoke session failed", zap.String("user_id", userID.String()), zap.String("session_id", sessionID.String()), zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to revoke session",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Session has been revoked",
})
}
// RevokeAllSessions handles DELETE /auth/sessions
func (h *AuthHandler) RevokeAllSessions(c *gin.Context) {
userID := getUserIDFromContext(c)
if userID == uuid.Nil {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authentication required",
})
return
}
actorID := getActorFromContext(c)
err := h.authService.RevokeAllSessions(c.Request.Context(), userID, actorID)
if err != nil {
h.logger.Error("Revoke all sessions failed", zap.String("user_id", userID.String()), zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to revoke all sessions",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "All sessions have been revoked",
})
}
// Helper functions
func getTokenFromHeader(c *gin.Context) string {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
return ""
}
// Remove "Bearer " prefix
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
return authHeader[7:]
}
return authHeader
}
func getIPAddress(c *gin.Context) string {
// Check for forwarded IP addresses
if forwarded := c.GetHeader("X-Forwarded-For"); forwarded != "" {
return forwarded
}
if realIP := c.GetHeader("X-Real-IP"); realIP != "" {
return realIP
}
return c.ClientIP()
}
func getUserIDFromContext(c *gin.Context) uuid.UUID {
if userID, exists := c.Get("user_id"); exists {
if id, ok := userID.(uuid.UUID); ok {
return id
}
if idStr, ok := userID.(string); ok {
if id, err := uuid.Parse(idStr); err == nil {
return id
}
}
}
return uuid.Nil
}

View File

@ -2,6 +2,7 @@ package interfaces
import (
"context"
"time"
"github.com/google/uuid"
"github.com/RyanCopley/skybridge/user/internal/domain"
@ -35,6 +36,14 @@ type UserRepository interface {
// ExistsByEmail checks if a user exists with the given email
ExistsByEmail(ctx context.Context, email string) (bool, error)
// Security methods
IncrementFailedAttempts(ctx context.Context, userID uuid.UUID, lockoutDuration time.Duration) error
ResetFailedAttempts(ctx context.Context, userID uuid.UUID) error
GetFailedAttempts(ctx context.Context, userID uuid.UUID) (int, *time.Time, error)
SetEmailVerified(ctx context.Context, userID uuid.UUID, verified bool) error
UpdatePassword(ctx context.Context, userID uuid.UUID, passwordHash string) error
UpdateTwoFactorSettings(ctx context.Context, userID uuid.UUID, enabled bool, secret *string, backupCodes []string) error
}
// UserProfileRepository defines the interface for user profile operations
@ -126,4 +135,39 @@ type GetEventsResponse struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
HasMore bool `json:"has_more"`
}
// PasswordResetTokenRepository defines the interface for password reset token operations
type PasswordResetTokenRepository interface {
Create(ctx context.Context, token *domain.PasswordResetToken) error
GetByToken(ctx context.Context, token string) (*domain.PasswordResetToken, error)
MarkAsUsed(ctx context.Context, tokenID uuid.UUID) error
DeleteExpired(ctx context.Context) error
DeleteByUserID(ctx context.Context, userID uuid.UUID) error
}
// EmailVerificationTokenRepository defines the interface for email verification token operations
type EmailVerificationTokenRepository interface {
Create(ctx context.Context, token *domain.EmailVerificationToken) error
GetByToken(ctx context.Context, token string) (*domain.EmailVerificationToken, error)
MarkAsUsed(ctx context.Context, tokenID uuid.UUID) error
DeleteExpired(ctx context.Context) error
DeleteByUserID(ctx context.Context, userID uuid.UUID) error
}
// LoginAttemptRepository defines the interface for login attempt tracking
type LoginAttemptRepository interface {
Create(ctx context.Context, attempt *domain.LoginAttempt) error
GetRecentAttempts(ctx context.Context, email string, since time.Time) ([]domain.LoginAttempt, error)
GetFailedAttemptsCount(ctx context.Context, email string, since time.Time) (int, error)
DeleteOldAttempts(ctx context.Context, before time.Time) error
}
// TwoFactorRecoveryCodeRepository defines the interface for 2FA recovery code operations
type TwoFactorRecoveryCodeRepository interface {
Create(ctx context.Context, codes []domain.TwoFactorRecoveryCode) error
GetByUserID(ctx context.Context, userID uuid.UUID) ([]domain.TwoFactorRecoveryCode, error)
MarkAsUsed(ctx context.Context, codeID uuid.UUID) error
DeleteByUserID(ctx context.Context, userID uuid.UUID) error
ValidateCode(ctx context.Context, userID uuid.UUID, codeHash string) (bool, error)
}

View File

@ -28,10 +28,16 @@ func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
query := `
INSERT INTO users (
id, email, first_name, last_name, display_name, avatar,
role, status, created_at, updated_at, created_by, updated_by
role, status, password_hash, password_salt, email_verified,
email_verification_token, email_verification_expires_at,
two_factor_enabled, two_factor_secret, two_factor_backup_codes,
created_at, updated_at, created_by, updated_by
) VALUES (
:id, :email, :first_name, :last_name, :display_name, :avatar,
:role, :status, :created_at, :updated_at, :created_by, :updated_by
:role, :status, :password_hash, :password_salt, :email_verified,
:email_verification_token, :email_verification_expires_at,
:two_factor_enabled, :two_factor_secret, :two_factor_backup_codes,
:created_at, :updated_at, :created_by, :updated_by
)`
if user.ID == uuid.Nil {
@ -58,7 +64,11 @@ func (r *userRepository) Create(ctx context.Context, user *domain.User) error {
func (r *userRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error) {
query := `
SELECT id, email, first_name, last_name, display_name, avatar,
role, status, last_login_at, created_at, updated_at, created_by, updated_by
role, status, last_login_at, password_hash, password_salt,
email_verified, email_verification_token, email_verification_expires_at,
password_reset_token, password_reset_expires_at, failed_login_attempts,
locked_until, two_factor_enabled, two_factor_secret, two_factor_backup_codes,
last_password_change, created_at, updated_at, created_by, updated_by
FROM users
WHERE id = $1`
@ -77,7 +87,11 @@ func (r *userRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.Use
func (r *userRepository) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
query := `
SELECT id, email, first_name, last_name, display_name, avatar,
role, status, last_login_at, created_at, updated_at, created_by, updated_by
role, status, last_login_at, password_hash, password_salt,
email_verified, email_verification_token, email_verification_expires_at,
password_reset_token, password_reset_expires_at, failed_login_attempts,
locked_until, two_factor_enabled, two_factor_secret, two_factor_backup_codes,
last_password_change, created_at, updated_at, created_by, updated_by
FROM users
WHERE email = $1`
@ -302,4 +316,133 @@ func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool,
}
return exists, nil
}
// Security methods
func (r *userRepository) IncrementFailedAttempts(ctx context.Context, userID uuid.UUID, lockoutDuration time.Duration) error {
query := `
UPDATE users SET
failed_login_attempts = failed_login_attempts + 1,
locked_until = CASE
WHEN failed_login_attempts + 1 >= 5 THEN $2
ELSE locked_until
END,
updated_at = $3
WHERE id = $1`
_, err := r.db.ExecContext(ctx, query, userID, time.Now().Add(lockoutDuration), time.Now())
if err != nil {
return fmt.Errorf("failed to increment failed attempts: %w", err)
}
return nil
}
func (r *userRepository) ResetFailedAttempts(ctx context.Context, userID uuid.UUID) error {
query := `
UPDATE users SET
failed_login_attempts = 0,
locked_until = NULL,
updated_at = $2
WHERE id = $1`
_, err := r.db.ExecContext(ctx, query, userID, time.Now())
if err != nil {
return fmt.Errorf("failed to reset failed attempts: %w", err)
}
return nil
}
func (r *userRepository) GetFailedAttempts(ctx context.Context, userID uuid.UUID) (int, *time.Time, error) {
query := `SELECT failed_login_attempts, locked_until FROM users WHERE id = $1`
var attempts int
var lockedUntil *time.Time
err := r.db.QueryRowContext(ctx, query, userID).Scan(&attempts, &lockedUntil)
if err != nil {
if err == sql.ErrNoRows {
return 0, nil, fmt.Errorf("user not found")
}
return 0, nil, fmt.Errorf("failed to get failed attempts: %w", err)
}
return attempts, lockedUntil, nil
}
func (r *userRepository) SetEmailVerified(ctx context.Context, userID uuid.UUID, verified bool) error {
query := `
UPDATE users SET
email_verified = $2,
email_verification_token = NULL,
email_verification_expires_at = NULL,
updated_at = $3
WHERE id = $1`
result, err := r.db.ExecContext(ctx, query, userID, verified, time.Now())
if err != nil {
return fmt.Errorf("failed to set email verified: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("user not found")
}
return nil
}
func (r *userRepository) UpdatePassword(ctx context.Context, userID uuid.UUID, passwordHash string) error {
query := `
UPDATE users SET
password_hash = $2,
last_password_change = $3,
password_reset_token = NULL,
password_reset_expires_at = NULL,
updated_at = $3
WHERE id = $1`
result, err := r.db.ExecContext(ctx, query, userID, passwordHash, time.Now())
if err != nil {
return fmt.Errorf("failed to update password: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("user not found")
}
return nil
}
func (r *userRepository) UpdateTwoFactorSettings(ctx context.Context, userID uuid.UUID, enabled bool, secret *string, backupCodes []string) error {
query := `
UPDATE users SET
two_factor_enabled = $2,
two_factor_secret = $3,
two_factor_backup_codes = $4,
updated_at = $5
WHERE id = $1`
result, err := r.db.ExecContext(ctx, query, userID, enabled, secret, pq.Array(backupCodes), time.Now())
if err != nil {
return fmt.Errorf("failed to update two factor settings: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("user not found")
}
return nil
}

View File

@ -0,0 +1,654 @@
package services
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base32"
"encoding/base64"
"fmt"
"net/url"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"github.com/RyanCopley/skybridge/user/internal/domain"
"github.com/RyanCopley/skybridge/user/internal/repository/interfaces"
)
// AuthService defines the interface for authentication operations
type AuthService interface {
// Authentication
Login(ctx context.Context, req *domain.LoginRequest, ipAddress, userAgent string) (*domain.LoginResponse, error)
Register(ctx context.Context, req *domain.RegisterRequest, ipAddress, userAgent string) (*domain.RegisterResponse, error)
Logout(ctx context.Context, token string) error
RefreshToken(ctx context.Context, token string) (*domain.LoginResponse, error)
// Password management
ForgotPassword(ctx context.Context, req *domain.ForgotPasswordRequest, ipAddress, userAgent string) error
ResetPassword(ctx context.Context, req *domain.ResetPasswordRequest, ipAddress, userAgent string) error
ChangePassword(ctx context.Context, userID uuid.UUID, req *domain.ChangePasswordRequest, actorID string) error
// Email verification
VerifyEmail(ctx context.Context, req *domain.VerifyEmailRequest, ipAddress, userAgent string) error
ResendVerification(ctx context.Context, req *domain.ResendVerificationRequest, ipAddress, userAgent string) error
// Two-factor authentication
SetupTwoFactor(ctx context.Context, userID uuid.UUID) (*domain.SetupTwoFactorResponse, error)
EnableTwoFactor(ctx context.Context, userID uuid.UUID, req *domain.EnableTwoFactorRequest, actorID string) error
DisableTwoFactor(ctx context.Context, userID uuid.UUID, req *domain.DisableTwoFactorRequest, actorID string) error
ValidateTwoFactor(ctx context.Context, req *domain.ValidateTwoFactorRequest, ipAddress, userAgent string) (*domain.LoginResponse, error)
RegenerateTwoFactorBackupCodes(ctx context.Context, userID uuid.UUID, actorID string) ([]string, error)
// Session management
GetUserSessions(ctx context.Context, userID uuid.UUID) (*domain.ListSessionsResponse, error)
RevokeSession(ctx context.Context, userID uuid.UUID, sessionID uuid.UUID, actorID string) error
RevokeAllSessions(ctx context.Context, userID uuid.UUID, actorID string) error
// Token validation
ValidateToken(ctx context.Context, tokenString string) (*domain.AuthContext, error)
// Security utilities
IsAccountLocked(ctx context.Context, email string) (bool, *time.Time, error)
RecordLoginAttempt(ctx context.Context, email, ipAddress, userAgent, failureReason string, success bool) error
}
type authService struct {
userRepo interfaces.UserRepository
sessionRepo interfaces.UserSessionRepository
auditRepo interfaces.AuditRepository
logger *zap.Logger
jwtSecret string
jwtIssuer string
tokenExpiry time.Duration
bcryptCost int
maxLoginAttempts int
lockoutDuration time.Duration
}
// JWT Claims structure
type JWTClaims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
SessionID string `json:"session_id"`
TwoFactor bool `json:"two_factor,omitempty"`
TempToken bool `json:"temp_token,omitempty"`
jwt.RegisteredClaims
}
// NewAuthService creates a new authentication service
func NewAuthService(
userRepo interfaces.UserRepository,
sessionRepo interfaces.UserSessionRepository,
auditRepo interfaces.AuditRepository,
logger *zap.Logger,
jwtSecret string,
jwtIssuer string,
) AuthService {
return &authService{
userRepo: userRepo,
sessionRepo: sessionRepo,
auditRepo: auditRepo,
logger: logger,
jwtSecret: jwtSecret,
jwtIssuer: jwtIssuer,
tokenExpiry: 24 * time.Hour,
bcryptCost: 12,
maxLoginAttempts: 5,
lockoutDuration: 15 * time.Minute,
}
}
func (s *authService) Register(ctx context.Context, req *domain.RegisterRequest, ipAddress, userAgent string) (*domain.RegisterResponse, error) {
// Check if user already exists
exists, err := s.userRepo.ExistsByEmail(ctx, req.Email)
if err != nil {
return nil, fmt.Errorf("failed to check user existence: %w", err)
}
if exists {
return nil, fmt.Errorf("user with email %s already exists", req.Email)
}
// Hash password
passwordHash, err := s.hashPassword(req.Password)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %w", err)
}
// Generate email verification token
verificationToken, err := s.generateSecureToken()
if err != nil {
return nil, fmt.Errorf("failed to generate verification token: %w", err)
}
// Create user
user := &domain.User{
ID: uuid.New(),
Email: req.Email,
FirstName: req.FirstName,
LastName: req.LastName,
DisplayName: req.DisplayName,
Role: domain.UserRoleUser,
Status: domain.UserStatusPending,
PasswordHash: passwordHash,
EmailVerified: false,
EmailVerificationToken: &verificationToken,
EmailVerificationExpiresAt: timePtr(time.Now().Add(24 * time.Hour)),
TwoFactorEnabled: false,
CreatedBy: req.Email,
UpdatedBy: req.Email,
}
err = s.userRepo.Create(ctx, user)
if err != nil {
return nil, fmt.Errorf("failed to create user: %w", err)
}
// Log registration
if s.auditRepo != nil {
auditEvent := &interfaces.AuditEvent{
ID: uuid.New(),
Type: "auth.register",
Severity: "info",
Status: "success",
Timestamp: time.Now().Format(time.RFC3339),
ActorID: user.Email,
ActorType: "user",
ActorIP: ipAddress,
UserAgent: userAgent,
ResourceID: user.ID.String(),
ResourceType: "user",
Action: "register",
Description: fmt.Sprintf("User %s registered", user.Email),
Details: map[string]interface{}{
"user_id": user.ID.String(),
"email": user.Email,
},
}
_ = s.auditRepo.LogEvent(ctx, auditEvent)
}
// TODO: Send welcome email with verification link
return &domain.RegisterResponse{
User: user,
Message: "Registration successful. Please check your email for verification instructions.",
}, nil
}
func (s *authService) Login(ctx context.Context, req *domain.LoginRequest, ipAddress, userAgent string) (*domain.LoginResponse, error) {
// Check if account is locked
locked, lockedUntil, err := s.IsAccountLocked(ctx, req.Email)
if err != nil {
s.logger.Error("Failed to check account lock status", zap.String("email", req.Email), zap.Error(err))
return nil, fmt.Errorf("authentication failed")
}
if locked {
_ = s.RecordLoginAttempt(ctx, req.Email, ipAddress, userAgent, "account_locked", false)
return nil, fmt.Errorf("account is temporarily locked until %v", lockedUntil.Format(time.RFC3339))
}
// Get user
user, err := s.userRepo.GetByEmail(ctx, req.Email)
if err != nil {
_ = s.RecordLoginAttempt(ctx, req.Email, ipAddress, userAgent, "user_not_found", false)
return nil, fmt.Errorf("authentication failed")
}
// Verify password
if !s.verifyPassword(req.Password, user.PasswordHash) {
_ = s.RecordLoginAttempt(ctx, req.Email, ipAddress, userAgent, "invalid_password", false)
_ = s.incrementFailedAttempts(ctx, user.ID)
return nil, fmt.Errorf("authentication failed")
}
// Check user status
if user.Status != domain.UserStatusActive {
_ = s.RecordLoginAttempt(ctx, req.Email, ipAddress, userAgent, fmt.Sprintf("user_status_%s", user.Status), false)
return nil, fmt.Errorf("account is not active")
}
// Check email verification
if !user.EmailVerified {
_ = s.RecordLoginAttempt(ctx, req.Email, ipAddress, userAgent, "email_not_verified", false)
return nil, fmt.Errorf("please verify your email address before logging in")
}
// Handle two-factor authentication
if user.TwoFactorEnabled {
if req.TwoFactorCode == nil {
// Generate temporary token for 2FA completion
tempToken, err := s.generateTempTwoFactorToken(user, ipAddress, userAgent)
if err != nil {
return nil, fmt.Errorf("failed to generate temporary token: %w", err)
}
return &domain.LoginResponse{
RequiresTwoFactor: true,
TwoFactorTempToken: &tempToken,
}, nil
}
// Validate 2FA code
valid, err := s.validateTOTPCode(*req.TwoFactorCode, *user.TwoFactorSecret)
if err != nil || !valid {
_ = s.RecordLoginAttempt(ctx, req.Email, ipAddress, userAgent, "invalid_2fa_code", false)
return nil, fmt.Errorf("invalid two-factor authentication code")
}
}
// Reset failed login attempts on successful login
_ = s.resetFailedAttempts(ctx, user.ID)
// Create session
session, err := s.createSession(ctx, user, ipAddress, userAgent, req.RememberMe)
if err != nil {
return nil, fmt.Errorf("failed to create session: %w", err)
}
// Generate JWT token
token, expiresAt, err := s.generateJWTToken(user, session.ID.String(), false)
if err != nil {
return nil, fmt.Errorf("failed to generate token: %w", err)
}
// Update last login
_ = s.userRepo.UpdateLastLogin(ctx, user.ID)
// Record successful login
_ = s.RecordLoginAttempt(ctx, req.Email, ipAddress, userAgent, "", true)
// Log successful login
if s.auditRepo != nil {
auditEvent := &interfaces.AuditEvent{
ID: uuid.New(),
Type: "auth.login",
Severity: "info",
Status: "success",
Timestamp: time.Now().Format(time.RFC3339),
ActorID: user.Email,
ActorType: "user",
ActorIP: ipAddress,
UserAgent: userAgent,
ResourceID: user.ID.String(),
ResourceType: "user",
Action: "login",
Description: fmt.Sprintf("User %s logged in", user.Email),
SessionID: session.ID.String(),
Details: map[string]interface{}{
"user_id": user.ID.String(),
"session_id": session.ID.String(),
"two_factor": user.TwoFactorEnabled,
},
}
_ = s.auditRepo.LogEvent(ctx, auditEvent)
}
return &domain.LoginResponse{
User: user,
Token: token,
ExpiresAt: expiresAt,
}, nil
}
// Helper functions
func (s *authService) hashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), s.bcryptCost)
return string(bytes), err
}
func (s *authService) verifyPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
func (s *authService) generateSecureToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(bytes), nil
}
func (s *authService) generateJWTToken(user *domain.User, sessionID string, isTempToken bool) (string, time.Time, error) {
expiresAt := time.Now().Add(s.tokenExpiry)
if isTempToken {
expiresAt = time.Now().Add(5 * time.Minute) // Temp tokens expire in 5 minutes
}
claims := JWTClaims{
UserID: user.ID.String(),
Email: user.Email,
Role: string(user.Role),
SessionID: sessionID,
TwoFactor: user.TwoFactorEnabled,
TempToken: isTempToken,
RegisteredClaims: jwt.RegisteredClaims{
Subject: user.ID.String(),
Issuer: s.jwtIssuer,
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(expiresAt),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(s.jwtSecret))
if err != nil {
return "", time.Time{}, err
}
return tokenString, expiresAt, nil
}
func (s *authService) generateTempTwoFactorToken(user *domain.User, ipAddress, userAgent string) (string, error) {
token, _, err := s.generateJWTToken(user, "", true)
return token, err
}
func (s *authService) createSession(ctx context.Context, user *domain.User, ipAddress, userAgent string, rememberMe bool) (*domain.UserSession, error) {
expiresAt := time.Now().Add(s.tokenExpiry)
if rememberMe {
expiresAt = time.Now().Add(30 * 24 * time.Hour) // 30 days for remember me
}
sessionToken, err := s.generateSecureToken()
if err != nil {
return nil, err
}
session := &domain.UserSession{
ID: uuid.New(),
UserID: user.ID,
Token: sessionToken,
IPAddress: ipAddress,
UserAgent: userAgent,
ExpiresAt: expiresAt,
LastUsedAt: time.Now(),
}
err = s.sessionRepo.Create(ctx, session)
if err != nil {
return nil, err
}
return session, nil
}
func (s *authService) incrementFailedAttempts(ctx context.Context, userID uuid.UUID) error {
return s.userRepo.IncrementFailedAttempts(ctx, userID, s.lockoutDuration)
}
func (s *authService) resetFailedAttempts(ctx context.Context, userID uuid.UUID) error {
return s.userRepo.ResetFailedAttempts(ctx, userID)
}
func (s *authService) validateTOTPCode(code, secret string) (bool, error) {
return totp.Validate(code, secret, time.Now()), nil
}
func (s *authService) generateBackupCode() (string, error) {
// Generate 8-digit backup code
bytes := make([]byte, 4)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
code := ""
for _, b := range bytes {
code += fmt.Sprintf("%02d", int(b)%100)
}
return code, nil
}
// Utility function
func timePtr(t time.Time) *time.Time {
return &t
}
// Placeholder implementations for other methods (to be completed)
func (s *authService) Logout(ctx context.Context, token string) error {
// TODO: Implement logout
return nil
}
func (s *authService) RefreshToken(ctx context.Context, token string) (*domain.LoginResponse, error) {
// TODO: Implement refresh token
return nil, nil
}
func (s *authService) ForgotPassword(ctx context.Context, req *domain.ForgotPasswordRequest, ipAddress, userAgent string) error {
// TODO: Implement forgot password
return nil
}
func (s *authService) ResetPassword(ctx context.Context, req *domain.ResetPasswordRequest, ipAddress, userAgent string) error {
// TODO: Implement reset password
return nil
}
func (s *authService) ChangePassword(ctx context.Context, userID uuid.UUID, req *domain.ChangePasswordRequest, actorID string) error {
// TODO: Implement change password
return nil
}
func (s *authService) VerifyEmail(ctx context.Context, req *domain.VerifyEmailRequest, ipAddress, userAgent string) error {
// TODO: Implement email verification
return nil
}
func (s *authService) ResendVerification(ctx context.Context, req *domain.ResendVerificationRequest, ipAddress, userAgent string) error {
// TODO: Implement resend verification
return nil
}
func (s *authService) SetupTwoFactor(ctx context.Context, userID uuid.UUID) (*domain.SetupTwoFactorResponse, error) {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("user not found: %w", err)
}
if user.TwoFactorEnabled {
return nil, fmt.Errorf("two-factor authentication is already enabled")
}
// Generate TOTP secret
key, err := totp.Generate(totp.GenerateOpts{
Issuer: s.jwtIssuer,
AccountName: user.Email,
SecretSize: 32,
})
if err != nil {
return nil, fmt.Errorf("failed to generate TOTP secret: %w", err)
}
// Generate backup codes
backupCodes := make([]string, 10)
for i := 0; i < 10; i++ {
code, err := s.generateBackupCode()
if err != nil {
return nil, fmt.Errorf("failed to generate backup code: %w", err)
}
backupCodes[i] = code
}
// Create QR code URL
qrCodeURL := key.URL()
return &domain.SetupTwoFactorResponse{
Secret: key.Secret(),
QRCodeURL: qrCodeURL,
BackupCodes: backupCodes,
}, nil
}
func (s *authService) EnableTwoFactor(ctx context.Context, userID uuid.UUID, req *domain.EnableTwoFactorRequest, actorID string) error {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return fmt.Errorf("user not found: %w", err)
}
if user.TwoFactorEnabled {
return fmt.Errorf("two-factor authentication is already enabled")
}
if user.TwoFactorSecret == nil {
return fmt.Errorf("two-factor authentication setup required first")
}
// Validate the provided code
valid, err := s.validateTOTPCode(req.Code, *user.TwoFactorSecret)
if err != nil || !valid {
return fmt.Errorf("invalid verification code")
}
// Generate backup codes
backupCodes := make([]string, 10)
for i := 0; i < 10; i++ {
code, err := s.generateBackupCode()
if err != nil {
return fmt.Errorf("failed to generate backup code: %w", err)
}
backupCodes[i] = code
}
// Enable 2FA in database
err = s.userRepo.UpdateTwoFactorSettings(ctx, userID, true, user.TwoFactorSecret, backupCodes)
if err != nil {
return fmt.Errorf("failed to enable two-factor authentication: %w", err)
}
// Log audit event
if s.auditRepo != nil {
auditEvent := &interfaces.AuditEvent{
ID: uuid.New(),
Type: "auth.2fa_enabled",
Severity: "info",
Status: "success",
Timestamp: time.Now().Format(time.RFC3339),
ActorID: actorID,
ActorType: "user",
ResourceID: userID.String(),
ResourceType: "user",
Action: "enable_2fa",
Description: fmt.Sprintf("Two-factor authentication enabled for user %s", user.Email),
Details: map[string]interface{}{
"user_id": userID.String(),
"email": user.Email,
},
}
_ = s.auditRepo.LogEvent(ctx, auditEvent)
}
s.logger.Info("Two-factor authentication enabled",
zap.String("user_id", userID.String()),
zap.String("email", user.Email),
zap.String("actor", actorID))
return nil
}
func (s *authService) DisableTwoFactor(ctx context.Context, userID uuid.UUID, req *domain.DisableTwoFactorRequest, actorID string) error {
// TODO: Implement disable 2FA
return nil
}
func (s *authService) ValidateTwoFactor(ctx context.Context, req *domain.ValidateTwoFactorRequest, ipAddress, userAgent string) (*domain.LoginResponse, error) {
// TODO: Implement validate 2FA
return nil, nil
}
func (s *authService) RegenerateTwoFactorBackupCodes(ctx context.Context, userID uuid.UUID, actorID string) ([]string, error) {
// TODO: Implement regenerate backup codes
return nil, nil
}
func (s *authService) GetUserSessions(ctx context.Context, userID uuid.UUID) (*domain.ListSessionsResponse, error) {
// TODO: Implement get user sessions
return nil, nil
}
func (s *authService) RevokeSession(ctx context.Context, userID uuid.UUID, sessionID uuid.UUID, actorID string) error {
// TODO: Implement revoke session
return nil
}
func (s *authService) RevokeAllSessions(ctx context.Context, userID uuid.UUID, actorID string) error {
// TODO: Implement revoke all sessions
return nil
}
func (s *authService) ValidateToken(ctx context.Context, tokenString string) (*domain.AuthContext, error) {
// TODO: Implement token validation
return nil, nil
}
func (s *authService) IsAccountLocked(ctx context.Context, email string) (bool, *time.Time, error) {
user, err := s.userRepo.GetByEmail(ctx, email)
if err != nil {
return false, nil, nil // User doesn't exist, not locked
}
// Check if account is currently locked
if user.LockedUntil != nil && time.Now().Before(*user.LockedUntil) {
return true, user.LockedUntil, nil
}
// Clear expired lock
if user.LockedUntil != nil && time.Now().After(*user.LockedUntil) {
_ = s.userRepo.ResetFailedAttempts(ctx, user.ID)
}
return false, nil, nil
}
func (s *authService) RecordLoginAttempt(ctx context.Context, email, ipAddress, userAgent, failureReason string, success bool) error {
attempt := &domain.LoginAttempt{
ID: uuid.New(),
Email: email,
IPAddress: ipAddress,
UserAgent: &userAgent,
Success: success,
FailureReason: &failureReason,
AttemptedAt: time.Now(),
}
if failureReason == "" {
attempt.FailureReason = nil
}
// In a full implementation, you would use a LoginAttemptRepository
// For now, just log the audit event
if s.auditRepo != nil {
auditEvent := &interfaces.AuditEvent{
ID: uuid.New(),
Type: "auth.login_attempt",
Severity: "info",
Status: "success",
Timestamp: time.Now().Format(time.RFC3339),
ActorID: email,
ActorType: "user",
ActorIP: ipAddress,
UserAgent: userAgent,
Action: "login_attempt",
Description: fmt.Sprintf("Login attempt for %s: success=%v", email, success),
Details: map[string]interface{}{
"email": email,
"success": success,
"failure_reason": failureReason,
},
}
_ = s.auditRepo.LogEvent(ctx, auditEvent)
}
return nil
}

View File

@ -0,0 +1,21 @@
-- Drop new tables
DROP TABLE IF EXISTS two_factor_recovery_codes;
DROP TABLE IF EXISTS login_attempts;
DROP TABLE IF EXISTS email_verification_tokens;
DROP TABLE IF EXISTS password_reset_tokens;
-- Remove new columns from users table
ALTER TABLE users
DROP COLUMN IF EXISTS password_hash,
DROP COLUMN IF EXISTS password_salt,
DROP COLUMN IF EXISTS email_verified,
DROP COLUMN IF EXISTS email_verification_token,
DROP COLUMN IF EXISTS email_verification_expires_at,
DROP COLUMN IF EXISTS password_reset_token,
DROP COLUMN IF EXISTS password_reset_expires_at,
DROP COLUMN IF EXISTS failed_login_attempts,
DROP COLUMN IF EXISTS locked_until,
DROP COLUMN IF EXISTS two_factor_enabled,
DROP COLUMN IF EXISTS two_factor_secret,
DROP COLUMN IF EXISTS two_factor_backup_codes,
DROP COLUMN IF EXISTS last_password_change;

View File

@ -0,0 +1,90 @@
-- Add password and security fields to users table
ALTER TABLE users
ADD COLUMN IF NOT EXISTS password_hash VARCHAR(255),
ADD COLUMN IF NOT EXISTS password_salt VARCHAR(255),
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS email_verification_token VARCHAR(255),
ADD COLUMN IF NOT EXISTS email_verification_expires_at TIMESTAMP WITH TIME ZONE,
ADD COLUMN IF NOT EXISTS password_reset_token VARCHAR(255),
ADD COLUMN IF NOT EXISTS password_reset_expires_at TIMESTAMP WITH TIME ZONE,
ADD COLUMN IF NOT EXISTS failed_login_attempts INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS locked_until TIMESTAMP WITH TIME ZONE,
ADD COLUMN IF NOT EXISTS two_factor_enabled BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS two_factor_secret VARCHAR(255),
ADD COLUMN IF NOT EXISTS two_factor_backup_codes TEXT[],
ADD COLUMN IF NOT EXISTS last_password_change TIMESTAMP WITH TIME ZONE;
-- Create password_reset_tokens table for better tracking
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
ip_address VARCHAR(45),
user_agent TEXT
);
-- Create email_verification_tokens table
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
ip_address VARCHAR(45),
user_agent TEXT
);
-- Create login_attempts table for tracking failed attempts
CREATE TABLE IF NOT EXISTS login_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL,
ip_address VARCHAR(45) NOT NULL,
user_agent TEXT,
success BOOLEAN NOT NULL,
failure_reason VARCHAR(255),
attempted_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
session_id VARCHAR(255)
);
-- Create two_factor_recovery_codes table
CREATE TABLE IF NOT EXISTS two_factor_recovery_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash VARCHAR(255) NOT NULL,
used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
-- Add indexes for security tables
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id ON password_reset_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_expires_at ON password_reset_tokens(expires_at);
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_token ON email_verification_tokens(token);
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_user_id ON email_verification_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_expires_at ON email_verification_tokens(expires_at);
CREATE INDEX IF NOT EXISTS idx_login_attempts_email ON login_attempts(email);
CREATE INDEX IF NOT EXISTS idx_login_attempts_ip_address ON login_attempts(ip_address);
CREATE INDEX IF NOT EXISTS idx_login_attempts_attempted_at ON login_attempts(attempted_at);
CREATE INDEX IF NOT EXISTS idx_two_factor_recovery_codes_user_id ON two_factor_recovery_codes(user_id);
-- Add indexes for new user fields
CREATE INDEX IF NOT EXISTS idx_users_email_verified ON users(email_verified);
CREATE INDEX IF NOT EXISTS idx_users_email_verification_token ON users(email_verification_token);
CREATE INDEX IF NOT EXISTS idx_users_password_reset_token ON users(password_reset_token);
CREATE INDEX IF NOT EXISTS idx_users_locked_until ON users(locked_until);
CREATE INDEX IF NOT EXISTS idx_users_two_factor_enabled ON users(two_factor_enabled);
-- Update existing admin user to have verified email
UPDATE users SET
email_verified = TRUE,
password_hash = '$2a$12$dummy.hash.for.system.admin.user.replace.with.real',
last_password_change = NOW()
WHERE email = 'admin@example.com';

1
web/.gitignore vendored
View File

@ -1 +1,2 @@
dist
node_modules

1
web/dist/index.html vendored
View File

@ -1 +0,0 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Skybridge Shell</title><script defer="defer" src="/main.js"></script></head><body><div id="root"></div></body></html>

2
web/dist/main.js vendored

File diff suppressed because one or more lines are too long

View File

@ -1,61 +0,0 @@
/**
* @license React
* react-dom.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react-jsx-runtime.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* react.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @license React
* scheduler.production.min.js
*
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
/**
* @remix-run/router v1.23.0
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/
/**
* React Router v6.30.1
*
* Copyright (c) Remix Software Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/