-
This commit is contained in:
1
kms/web/.gitignore
vendored
1
kms/web/.gitignore
vendored
@ -1 +1,2 @@
|
|||||||
|
dist
|
||||||
node_modules
|
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 (
|
require (
|
||||||
github.com/gin-gonic/gin v1.10.0
|
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/google/uuid v1.6.0
|
||||||
github.com/jmoiron/sqlx v1.4.0
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
|
github.com/pquerna/otp v1.4.0
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
|
golang.org/x/crypto v0.23.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|||||||
@ -37,6 +37,22 @@ type User struct {
|
|||||||
Role UserRole `json:"role" validate:"required,oneof=admin user moderator viewer" db:"role"`
|
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"`
|
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"`
|
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"`
|
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||||
CreatedBy string `json:"created_by" validate:"required" db:"created_by"`
|
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
|
// CreateUserRequest represents a request to create a new user
|
||||||
type CreateUserRequest struct {
|
type CreateUserRequest struct {
|
||||||
Email string `json:"email" validate:"required,email,max=255"`
|
Email string `json:"email" validate:"required,email,max=255"`
|
||||||
FirstName string `json:"first_name" validate:"required,min=1,max=100"`
|
FirstName string `json:"first_name" validate:"required,min=1,max=100"`
|
||||||
LastName string `json:"last_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"`
|
DisplayName *string `json:"display_name,omitempty" validate:"omitempty,max=200"`
|
||||||
Avatar *string `json:"avatar,omitempty" validate:"omitempty,url,max=500"`
|
Avatar *string `json:"avatar,omitempty" validate:"omitempty,url,max=500"`
|
||||||
Role UserRole `json:"role" validate:"required,oneof=admin user moderator viewer"`
|
Role UserRole `json:"role" validate:"required,oneof=admin user moderator viewer"`
|
||||||
Status UserStatus `json:"status" validate:"omitempty,oneof=active inactive suspended pending"`
|
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
|
// UpdateUserRequest represents a request to update an existing user
|
||||||
@ -120,6 +138,52 @@ type ListUsersResponse struct {
|
|||||||
HasMore bool `json:"has_more"`
|
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
|
// AuthContext represents the authentication context for a request
|
||||||
type AuthContext struct {
|
type AuthContext struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
@ -127,4 +191,105 @@ type AuthContext struct {
|
|||||||
Role UserRole `json:"role"`
|
Role UserRole `json:"role"`
|
||||||
Permissions []string `json:"permissions"`
|
Permissions []string `json:"permissions"`
|
||||||
Claims map[string]string `json:"claims"`
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/RyanCopley/skybridge/user/internal/domain"
|
"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 checks if a user exists with the given email
|
||||||
ExistsByEmail(ctx context.Context, email string) (bool, error)
|
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
|
// UserProfileRepository defines the interface for user profile operations
|
||||||
@ -126,4 +135,39 @@ type GetEventsResponse struct {
|
|||||||
Limit int `json:"limit"`
|
Limit int `json:"limit"`
|
||||||
Offset int `json:"offset"`
|
Offset int `json:"offset"`
|
||||||
HasMore bool `json:"has_more"`
|
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 := `
|
query := `
|
||||||
INSERT INTO users (
|
INSERT INTO users (
|
||||||
id, email, first_name, last_name, display_name, avatar,
|
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 (
|
) VALUES (
|
||||||
:id, :email, :first_name, :last_name, :display_name, :avatar,
|
: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 {
|
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) {
|
func (r *userRepository) GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, email, first_name, last_name, display_name, avatar,
|
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
|
FROM users
|
||||||
WHERE id = $1`
|
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) {
|
func (r *userRepository) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, email, first_name, last_name, display_name, avatar,
|
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
|
FROM users
|
||||||
WHERE email = $1`
|
WHERE email = $1`
|
||||||
|
|
||||||
@ -302,4 +316,133 @@ func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool,
|
|||||||
}
|
}
|
||||||
|
|
||||||
return exists, nil
|
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
|
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