-
This commit is contained in:
1
kms/web/.gitignore
vendored
1
kms/web/.gitignore
vendored
@ -1 +1,2 @@
|
||||
dist
|
||||
node_modules
|
||||
|
||||
1
kms/web/dist/211.js
vendored
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
2
kms/web/dist/265.js
vendored
File diff suppressed because one or more lines are too long
21
kms/web/dist/265.js.LICENSE.txt
vendored
21
kms/web/dist/265.js.LICENSE.txt
vendored
@ -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
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
2
kms/web/dist/540.js
vendored
File diff suppressed because one or more lines are too long
9
kms/web/dist/540.js.LICENSE.txt
vendored
9
kms/web/dist/540.js.LICENSE.txt
vendored
@ -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
2
kms/web/dist/63.js
vendored
File diff suppressed because one or more lines are too long
9
kms/web/dist/63.js.LICENSE.txt
vendored
9
kms/web/dist/63.js.LICENSE.txt
vendored
@ -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
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
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
2
kms/web/dist/875.js
vendored
File diff suppressed because one or more lines are too long
9
kms/web/dist/875.js.LICENSE.txt
vendored
9
kms/web/dist/875.js.LICENSE.txt
vendored
@ -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
2
kms/web/dist/961.js
vendored
File diff suppressed because one or more lines are too long
19
kms/web/dist/961.js.LICENSE.txt
vendored
19
kms/web/dist/961.js.LICENSE.txt
vendored
@ -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.
|
||||
*/
|
||||
1
kms/web/dist/index.html
vendored
1
kms/web/dist/index.html
vendored
@ -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>
|
||||
2
kms/web/dist/main.js
vendored
2
kms/web/dist/main.js
vendored
File diff suppressed because one or more lines are too long
31
kms/web/dist/main.js.LICENSE.txt
vendored
31
kms/web/dist/main.js.LICENSE.txt
vendored
@ -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
|
||||
*/
|
||||
1
kms/web/dist/remoteEntry.js
vendored
1
kms/web/dist/remoteEntry.js
vendored
File diff suppressed because one or more lines are too long
@ -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 (
|
||||
|
||||
@ -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"`
|
||||
@ -77,6 +93,8 @@ type CreateUserRequest struct {
|
||||
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"`
|
||||
@ -128,3 +192,104 @@ type AuthContext struct {
|
||||
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"`
|
||||
}
|
||||
545
user/internal/handlers/auth_handler.go
Normal file
545
user/internal/handlers/auth_handler.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
@ -127,3 +136,38 @@ type GetEventsResponse struct {
|
||||
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)
|
||||
}
|
||||
@ -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`
|
||||
|
||||
@ -303,3 +317,132 @@ 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
|
||||
}
|
||||
654
user/internal/services/auth_service.go
Normal file
654
user/internal/services/auth_service.go
Normal 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
|
||||
}
|
||||
21
user/migrations/002_add_security_features.down.sql
Normal file
21
user/migrations/002_add_security_features.down.sql
Normal 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;
|
||||
90
user/migrations/002_add_security_features.up.sql
Normal file
90
user/migrations/002_add_security_features.up.sql
Normal 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
1
web/.gitignore
vendored
@ -1 +1,2 @@
|
||||
dist
|
||||
node_modules
|
||||
|
||||
1
web/dist/index.html
vendored
1
web/dist/index.html
vendored
@ -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
2
web/dist/main.js
vendored
File diff suppressed because one or more lines are too long
61
web/dist/main.js.LICENSE.txt
vendored
61
web/dist/main.js.LICENSE.txt
vendored
@ -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
|
||||
*/
|
||||
Reference in New Issue
Block a user