Initial commit: breakpilot-core - Shared Infrastructure
Docker Compose with 24+ services: - PostgreSQL (PostGIS), Valkey, MinIO, Qdrant - Vault (PKI/TLS), Nginx (Reverse Proxy) - Backend Core API, Consent Service, Billing Service - RAG Service, Embedding Service - Gitea, Woodpecker CI/CD - Night Scheduler, Health Aggregator - Jitsi (Web/XMPP/JVB/Jicofo), Mailpit Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
743
consent-service/internal/handlers/oauth_handlers.go
Normal file
743
consent-service/internal/handlers/oauth_handlers.go
Normal file
@@ -0,0 +1,743 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/breakpilot/consent-service/internal/middleware"
|
||||
"github.com/breakpilot/consent-service/internal/models"
|
||||
"github.com/breakpilot/consent-service/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// OAuthHandler handles OAuth 2.0 endpoints
|
||||
type OAuthHandler struct {
|
||||
oauthService *services.OAuthService
|
||||
totpService *services.TOTPService
|
||||
authService *services.AuthService
|
||||
}
|
||||
|
||||
// NewOAuthHandler creates a new OAuthHandler
|
||||
func NewOAuthHandler(oauthService *services.OAuthService, totpService *services.TOTPService, authService *services.AuthService) *OAuthHandler {
|
||||
return &OAuthHandler{
|
||||
oauthService: oauthService,
|
||||
totpService: totpService,
|
||||
authService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// OAuth 2.0 Authorization Code Flow
|
||||
// ========================================
|
||||
|
||||
// Authorize handles the OAuth 2.0 authorization request
|
||||
// GET /oauth/authorize
|
||||
func (h *OAuthHandler) Authorize(c *gin.Context) {
|
||||
responseType := c.Query("response_type")
|
||||
clientID := c.Query("client_id")
|
||||
redirectURI := c.Query("redirect_uri")
|
||||
scope := c.Query("scope")
|
||||
state := c.Query("state")
|
||||
codeChallenge := c.Query("code_challenge")
|
||||
codeChallengeMethod := c.Query("code_challenge_method")
|
||||
|
||||
// Validate response_type
|
||||
if responseType != "code" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "unsupported_response_type",
|
||||
"error_description": "Only 'code' response_type is supported",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate client
|
||||
ctx := context.Background()
|
||||
client, err := h.oauthService.ValidateClient(ctx, clientID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_client",
|
||||
"error_description": "Unknown or invalid client_id",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate redirect_uri
|
||||
if err := h.oauthService.ValidateRedirectURI(client, redirectURI); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Invalid redirect_uri",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate scopes
|
||||
scopes, err := h.oauthService.ValidateScopes(client, scope)
|
||||
if err != nil {
|
||||
redirectWithError(c, redirectURI, "invalid_scope", "One or more requested scopes are invalid", state)
|
||||
return
|
||||
}
|
||||
|
||||
// For public clients, PKCE is required
|
||||
if client.IsPublic && codeChallenge == "" {
|
||||
redirectWithError(c, redirectURI, "invalid_request", "PKCE code_challenge is required for public clients", state)
|
||||
return
|
||||
}
|
||||
|
||||
// Get authenticated user
|
||||
userID, err := middleware.GetUserID(c)
|
||||
if err != nil || userID == uuid.Nil {
|
||||
// User not authenticated - redirect to login
|
||||
// Store authorization request in session and redirect to login
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "login_required",
|
||||
"error_description": "User must be authenticated to authorize",
|
||||
"login_url": "/auth/login",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate authorization code
|
||||
code, err := h.oauthService.GenerateAuthorizationCode(
|
||||
ctx, client, userID, redirectURI, scopes, codeChallenge, codeChallengeMethod,
|
||||
)
|
||||
if err != nil {
|
||||
redirectWithError(c, redirectURI, "server_error", "Failed to generate authorization code", state)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect with code
|
||||
redirectURL := redirectURI + "?code=" + code
|
||||
if state != "" {
|
||||
redirectURL += "&state=" + state
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
}
|
||||
|
||||
// Token handles the OAuth 2.0 token request
|
||||
// POST /oauth/token
|
||||
func (h *OAuthHandler) Token(c *gin.Context) {
|
||||
grantType := c.PostForm("grant_type")
|
||||
|
||||
switch grantType {
|
||||
case "authorization_code":
|
||||
h.tokenAuthorizationCode(c)
|
||||
case "refresh_token":
|
||||
h.tokenRefreshToken(c)
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "unsupported_grant_type",
|
||||
"error_description": "Only 'authorization_code' and 'refresh_token' grant types are supported",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// tokenAuthorizationCode handles the authorization_code grant
|
||||
func (h *OAuthHandler) tokenAuthorizationCode(c *gin.Context) {
|
||||
code := c.PostForm("code")
|
||||
clientID := c.PostForm("client_id")
|
||||
redirectURI := c.PostForm("redirect_uri")
|
||||
codeVerifier := c.PostForm("code_verifier")
|
||||
|
||||
if code == "" || clientID == "" || redirectURI == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Missing required parameters: code, client_id, redirect_uri",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate client
|
||||
ctx := context.Background()
|
||||
client, err := h.oauthService.ValidateClient(ctx, clientID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_client",
|
||||
"error_description": "Unknown or invalid client_id",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// For confidential clients, validate client_secret
|
||||
if !client.IsPublic {
|
||||
clientSecret := c.PostForm("client_secret")
|
||||
if err := h.oauthService.ValidateClientSecret(client, clientSecret); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "invalid_client",
|
||||
"error_description": "Invalid client credentials",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
tokenResponse, err := h.oauthService.ExchangeAuthorizationCode(ctx, code, clientID, redirectURI, codeVerifier)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case services.ErrCodeExpired:
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "Authorization code has expired",
|
||||
})
|
||||
case services.ErrCodeUsed:
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "Authorization code has already been used",
|
||||
})
|
||||
case services.ErrPKCEVerifyFailed:
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "PKCE verification failed",
|
||||
})
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "Invalid authorization code",
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tokenResponse)
|
||||
}
|
||||
|
||||
// tokenRefreshToken handles the refresh_token grant
|
||||
func (h *OAuthHandler) tokenRefreshToken(c *gin.Context) {
|
||||
refreshToken := c.PostForm("refresh_token")
|
||||
clientID := c.PostForm("client_id")
|
||||
scope := c.PostForm("scope")
|
||||
|
||||
if refreshToken == "" || clientID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Missing required parameters: refresh_token, client_id",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Refresh access token
|
||||
tokenResponse, err := h.oauthService.RefreshAccessToken(ctx, refreshToken, clientID, scope)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case services.ErrInvalidScope:
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_scope",
|
||||
"error_description": "Requested scope exceeds original grant",
|
||||
})
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "Invalid or expired refresh token",
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tokenResponse)
|
||||
}
|
||||
|
||||
// Revoke handles token revocation
|
||||
// POST /oauth/revoke
|
||||
func (h *OAuthHandler) Revoke(c *gin.Context) {
|
||||
token := c.PostForm("token")
|
||||
tokenTypeHint := c.PostForm("token_type_hint")
|
||||
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Missing token parameter",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
_ = h.oauthService.RevokeToken(ctx, token, tokenTypeHint)
|
||||
|
||||
// RFC 7009: Always return 200 OK
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// Introspect handles token introspection (for resource servers)
|
||||
// POST /oauth/introspect
|
||||
func (h *OAuthHandler) Introspect(c *gin.Context) {
|
||||
token := c.PostForm("token")
|
||||
|
||||
if token == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid_request",
|
||||
"error_description": "Missing token parameter",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
claims, err := h.oauthService.ValidateAccessToken(ctx, token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"active": false})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"active": true,
|
||||
"sub": (*claims)["sub"],
|
||||
"client_id": (*claims)["client_id"],
|
||||
"scope": (*claims)["scope"],
|
||||
"exp": (*claims)["exp"],
|
||||
"iat": (*claims)["iat"],
|
||||
"iss": (*claims)["iss"],
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 2FA (TOTP) Endpoints
|
||||
// ========================================
|
||||
|
||||
// Setup2FA initiates 2FA setup
|
||||
// POST /auth/2fa/setup
|
||||
func (h *OAuthHandler) Setup2FA(c *gin.Context) {
|
||||
userID, err := middleware.GetUserID(c)
|
||||
if err != nil || userID == uuid.Nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user email
|
||||
ctx := context.Background()
|
||||
user, err := h.authService.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
|
||||
return
|
||||
}
|
||||
|
||||
// Setup 2FA
|
||||
response, err := h.totpService.Setup2FA(ctx, userID, user.Email)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case services.ErrTOTPAlreadyEnabled:
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "2FA is already enabled for this account"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to setup 2FA"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// Verify2FASetup verifies the 2FA setup with a code
|
||||
// POST /auth/2fa/verify-setup
|
||||
func (h *OAuthHandler) Verify2FASetup(c *gin.Context) {
|
||||
userID, err := middleware.GetUserID(c)
|
||||
if err != nil || userID == uuid.Nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.Verify2FARequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err = h.totpService.Verify2FASetup(ctx, userID, req.Code)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case services.ErrTOTPAlreadyEnabled:
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "2FA is already enabled"})
|
||||
case services.ErrTOTPInvalidCode:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify 2FA setup"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "2FA enabled successfully"})
|
||||
}
|
||||
|
||||
// Verify2FAChallenge verifies a 2FA challenge during login
|
||||
// POST /auth/2fa/verify
|
||||
func (h *OAuthHandler) Verify2FAChallenge(c *gin.Context) {
|
||||
var req models.Verify2FAChallengeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
var userID *uuid.UUID
|
||||
var err error
|
||||
|
||||
if req.RecoveryCode != "" {
|
||||
// Verify with recovery code
|
||||
userID, err = h.totpService.VerifyChallengeWithRecoveryCode(ctx, req.ChallengeID, req.RecoveryCode)
|
||||
} else {
|
||||
// Verify with TOTP code
|
||||
userID, err = h.totpService.VerifyChallenge(ctx, req.ChallengeID, req.Code)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
switch err {
|
||||
case services.ErrTOTPChallengeExpired:
|
||||
c.JSON(http.StatusGone, gin.H{"error": "2FA challenge has expired"})
|
||||
case services.ErrTOTPInvalidCode:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
|
||||
case services.ErrRecoveryCodeInvalid:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid recovery code"})
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "2FA verification failed"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Get user and generate tokens
|
||||
user, err := h.authService.GetUserByID(ctx, *userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate access token
|
||||
accessToken, err := h.authService.GenerateAccessToken(user)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Generate refresh token
|
||||
refreshToken, refreshTokenHash, err := h.authService.GenerateRefreshToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Store session
|
||||
ipAddress := middleware.GetClientIP(c)
|
||||
userAgent := middleware.GetUserAgent(c)
|
||||
|
||||
// We need direct DB access for this, or we need to add a method to AuthService
|
||||
// For now, we'll return the tokens and let the caller handle session storage
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshToken,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"user": map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
"role": user.Role,
|
||||
},
|
||||
"_session_hash": refreshTokenHash,
|
||||
"_ip": ipAddress,
|
||||
"_user_agent": userAgent,
|
||||
})
|
||||
}
|
||||
|
||||
// Disable2FA disables 2FA for the current user
|
||||
// POST /auth/2fa/disable
|
||||
func (h *OAuthHandler) Disable2FA(c *gin.Context) {
|
||||
userID, err := middleware.GetUserID(c)
|
||||
if err != nil || userID == uuid.Nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.Verify2FARequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err = h.totpService.Disable2FA(ctx, userID, req.Code)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case services.ErrTOTPNotEnabled:
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "2FA is not enabled"})
|
||||
case services.ErrTOTPInvalidCode:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disable 2FA"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "2FA disabled successfully"})
|
||||
}
|
||||
|
||||
// Get2FAStatus returns the 2FA status for the current user
|
||||
// GET /auth/2fa/status
|
||||
func (h *OAuthHandler) Get2FAStatus(c *gin.Context) {
|
||||
userID, err := middleware.GetUserID(c)
|
||||
if err != nil || userID == uuid.Nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
status, err := h.totpService.GetStatus(ctx, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get 2FA status"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, status)
|
||||
}
|
||||
|
||||
// RegenerateRecoveryCodes generates new recovery codes
|
||||
// POST /auth/2fa/recovery-codes
|
||||
func (h *OAuthHandler) RegenerateRecoveryCodes(c *gin.Context) {
|
||||
userID, err := middleware.GetUserID(c)
|
||||
if err != nil || userID == uuid.Nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.Verify2FARequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
codes, err := h.totpService.RegenerateRecoveryCodes(ctx, userID, req.Code)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case services.ErrTOTPNotEnabled:
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "2FA is not enabled"})
|
||||
case services.ErrTOTPInvalidCode:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to regenerate recovery codes"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"recovery_codes": codes})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Enhanced Login with 2FA
|
||||
// ========================================
|
||||
|
||||
// LoginWith2FA handles login with optional 2FA
|
||||
// POST /auth/login
|
||||
func (h *OAuthHandler) LoginWith2FA(c *gin.Context) {
|
||||
var req models.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
ipAddress := middleware.GetClientIP(c)
|
||||
userAgent := middleware.GetUserAgent(c)
|
||||
|
||||
// Attempt login
|
||||
response, err := h.authService.Login(ctx, &req, ipAddress, userAgent)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case services.ErrInvalidCredentials:
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"})
|
||||
case services.ErrAccountLocked:
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Account is temporarily locked"})
|
||||
case services.ErrAccountSuspended:
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Account is suspended"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Login failed"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if 2FA is enabled
|
||||
twoFactorEnabled, _ := h.totpService.IsTwoFactorEnabled(ctx, response.User.ID)
|
||||
|
||||
if twoFactorEnabled {
|
||||
// Create 2FA challenge
|
||||
challengeID, err := h.totpService.CreateChallenge(ctx, response.User.ID, ipAddress, userAgent)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create 2FA challenge"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return 2FA required response
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"requires_2fa": true,
|
||||
"challenge_id": challengeID,
|
||||
"message": "2FA verification required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// No 2FA required, return tokens
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"requires_2fa": false,
|
||||
"access_token": response.AccessToken,
|
||||
"refresh_token": response.RefreshToken,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": response.ExpiresIn,
|
||||
"user": map[string]interface{}{
|
||||
"id": response.User.ID,
|
||||
"email": response.User.Email,
|
||||
"name": response.User.Name,
|
||||
"role": response.User.Role,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Registration with mandatory 2FA setup
|
||||
// ========================================
|
||||
|
||||
// RegisterWith2FA handles registration with mandatory 2FA setup
|
||||
// POST /auth/register
|
||||
func (h *OAuthHandler) RegisterWith2FA(c *gin.Context) {
|
||||
var req models.RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Validate password strength
|
||||
if len(req.Password) < 8 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Password must be at least 8 characters"})
|
||||
return
|
||||
}
|
||||
|
||||
// Register user
|
||||
user, verificationToken, err := h.authService.Register(ctx, &req)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case services.ErrUserExists:
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "A user with this email already exists"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Registration failed"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Setup 2FA immediately
|
||||
twoFAResponse, err := h.totpService.Setup2FA(ctx, user.ID, user.Email)
|
||||
if err != nil {
|
||||
// Non-fatal - user can set up 2FA later, but log it
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"message": "Registration successful. Please verify your email.",
|
||||
"user_id": user.ID,
|
||||
"verification_token": verificationToken, // In production, this would be sent via email
|
||||
"two_factor_setup": nil,
|
||||
"two_factor_error": "Failed to initialize 2FA. Please set it up in your account settings.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"message": "Registration successful. Please verify your email and complete 2FA setup.",
|
||||
"user_id": user.ID,
|
||||
"verification_token": verificationToken, // In production, this would be sent via email
|
||||
"two_factor_setup": map[string]interface{}{
|
||||
"secret": twoFAResponse.Secret,
|
||||
"qr_code": twoFAResponse.QRCodeDataURL,
|
||||
"recovery_codes": twoFAResponse.RecoveryCodes,
|
||||
"setup_required": true,
|
||||
"setup_endpoint": "/auth/2fa/verify-setup",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// OAuth Client Management (Admin)
|
||||
// ========================================
|
||||
|
||||
// AdminCreateClient creates a new OAuth client
|
||||
// POST /admin/oauth/clients
|
||||
func (h *OAuthHandler) AdminCreateClient(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
RedirectURIs []string `json:"redirect_uris" binding:"required"`
|
||||
Scopes []string `json:"scopes"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := middleware.GetUserID(c)
|
||||
|
||||
// Default scopes
|
||||
if len(req.Scopes) == 0 {
|
||||
req.Scopes = []string{"openid", "profile", "email"}
|
||||
}
|
||||
|
||||
// Default grant types
|
||||
if len(req.GrantTypes) == 0 {
|
||||
req.GrantTypes = []string{"authorization_code", "refresh_token"}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
client, clientSecret, err := h.oauthService.CreateClient(
|
||||
ctx, req.Name, req.Description, req.RedirectURIs, req.Scopes, req.GrantTypes, req.IsPublic, &userID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create client"})
|
||||
return
|
||||
}
|
||||
|
||||
response := gin.H{
|
||||
"client_id": client.ClientID,
|
||||
"name": client.Name,
|
||||
"redirect_uris": client.RedirectURIs,
|
||||
"scopes": client.Scopes,
|
||||
"grant_types": client.GrantTypes,
|
||||
"is_public": client.IsPublic,
|
||||
}
|
||||
|
||||
// Only show client_secret once for confidential clients
|
||||
if !client.IsPublic && clientSecret != "" {
|
||||
response["client_secret"] = clientSecret
|
||||
response["client_secret_warning"] = "Store this secret securely. It will not be shown again."
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, response)
|
||||
}
|
||||
|
||||
// AdminGetClients lists all OAuth clients
|
||||
// GET /admin/oauth/clients
|
||||
func (h *OAuthHandler) AdminGetClients(c *gin.Context) {
|
||||
// This would need a new method in OAuthService
|
||||
// For now, return a placeholder
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"clients": []interface{}{},
|
||||
"message": "Client listing not yet implemented",
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Helper Functions
|
||||
// ========================================
|
||||
|
||||
func redirectWithError(c *gin.Context, redirectURI, errorCode, errorDescription, state string) {
|
||||
separator := "?"
|
||||
if strings.Contains(redirectURI, "?") {
|
||||
separator = "&"
|
||||
}
|
||||
|
||||
redirectURL := redirectURI + separator + "error=" + errorCode + "&error_description=" + errorDescription
|
||||
if state != "" {
|
||||
redirectURL += "&state=" + state
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
}
|
||||
Reference in New Issue
Block a user