mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
* feat(branding): white-label — public /branding, admin write, shell theming app_settings branding.* keys (product name, logo/favicon URLs, accent color, docs/support links). Public GET /branding so login and shell theme pre-auth; admin writes via PUT /settings. useBranding() applies document.title, accent CSS var and favicon; BrandWordmark replaces hardcoded wordmarks; admin Platform settings gain a Branding form. MCP containr_branding_get + settings_update. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(invites): team invite links — admin create/revoke, public accept Admins mint single-use invite links (sha256-hashed token at rest, optional email binding, configurable TTL). Public endpoints let the accept page validate the link and register — working even while public signup is disabled. - migration + sqlc queries/model for user_invites - POST /admin/invites (returns one-time token+url), GET list, DELETE revoke - GET /auth/invites/:token public validation, POST /auth/accept-invite - CLI containr invites list|create|revoke; MCP invites tools - /auth/accept-invite page (expired/used/invalid states, bound-email lock) - Admin page Team invites section: create, copy link, revoke - OpenAPI + generated types + CLI guide Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(admin): impersonate user — 15-min token, audit-logged POST /admin/users/:id/impersonate mints a short-lived bearer token (impersonated_by claim) for support/debugging. Admin accounts cannot be impersonated; every mint is audit-logged with actor + target. - CLI: containr admin impersonate <user-id> (works via CONTAINR_TOKEN) - MCP: containr_admin_impersonate - Admin UI: per-user Impersonate action → token modal with usage hint - OpenAPI + generated types Verified live: token resolves to target, admin target 403, audit row. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(git): GitHub App manifest self-provisioning + app-level webhook Admins create the instance GitHub App in-browser instead of hand- setting GITHUB_APP_* env vars: - POST /admin/git/github-app/manifest builds the manifest + URL; the frontend auto-submits it to github.com - /git/github-app/callback page exchanges the code via POST /admin/git/github-app/convert; credentials persist in app_settings (precedence over env) - GET /admin/git/github-app reports configured/source - POST /api/git/github-app/webhook — single app-level push receiver, HMAC-verified, dispatches via extracted dispatchPushToServices - install-url + installation-token paths resolve settings→env - CLI: containr admin github-app status|manifest|convert; MCP tools - Git providers UI shows "Set up GitHub App" for admins when unconfigured Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
581 lines
16 KiB
Go
581 lines
16 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"containr/internal/database"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
type LoginRequest struct {
|
|
Email string `json:"email" binding:"required,email"`
|
|
Password string `json:"password" binding:"required,min=6"`
|
|
}
|
|
|
|
type RegisterRequest struct {
|
|
Email string `json:"email" binding:"required,email"`
|
|
Password string `json:"password" binding:"required,min=8,max=128"`
|
|
Name string `json:"name" binding:"required,min=2"`
|
|
}
|
|
|
|
type ManualUserCreateRequest struct {
|
|
Email string `json:"email" binding:"required,email"`
|
|
Password string `json:"password" binding:"required,min=8,max=128"`
|
|
Name string `json:"name" binding:"required,min=2,max=255"`
|
|
}
|
|
|
|
type AuthResponse struct {
|
|
Token string `json:"token"`
|
|
User interface{} `json:"user"`
|
|
}
|
|
|
|
type User struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
AvatarURL string `json:"avatar_url,omitempty"`
|
|
IsAdmin bool `json:"is_admin"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
func handleLogin(c *gin.Context) {
|
|
var req LoginRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
db := c.MustGet("db").(*database.DB)
|
|
jwtSecret := c.MustGet("jwt_secret").(string)
|
|
|
|
// Find user by email
|
|
var user User
|
|
var hashedPassword string
|
|
err := db.QueryRow(`
|
|
SELECT id, email, password_hash, name, COALESCE(avatar_url, ''), is_admin, created_at
|
|
FROM users
|
|
WHERE email = $1
|
|
`, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
|
|
|
|
if err == sql.ErrNoRows {
|
|
authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password)
|
|
if !ok {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
|
return
|
|
}
|
|
|
|
user, err = createLocalUserFromBetterAuth(db, authUser)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create local user"})
|
|
return
|
|
}
|
|
} else if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
return
|
|
} else {
|
|
// Check password
|
|
if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(req.Password)); err != nil {
|
|
if _, ok := verifyBetterAuthCredentials(c, req.Email, req.Password); !ok {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Generate JWT token
|
|
token, err := generateJWT(user.ID, user.Email, jwtSecret)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, AuthResponse{
|
|
Token: token,
|
|
User: user,
|
|
})
|
|
}
|
|
|
|
func handleAuthBootstrap(c *gin.Context) {
|
|
db := c.MustGet("db").(*database.DB)
|
|
|
|
count, err := countLocalUsers(db)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to inspect auth bootstrap state"})
|
|
return
|
|
}
|
|
|
|
mode := "login"
|
|
if count == 0 {
|
|
mode = "register"
|
|
}
|
|
|
|
providers := []string{}
|
|
for _, provider := range []string{"GITHUB", "GOOGLE"} {
|
|
if oauthProviderConfigured(provider) {
|
|
providers = append(providers, strings.ToLower(provider))
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"has_users": count > 0,
|
|
"user_count": count,
|
|
"mode": mode,
|
|
"providers": providers,
|
|
"signup_enabled": signupEnabled(db),
|
|
})
|
|
}
|
|
|
|
// oauthProviderConfigured reports whether an OAuth provider has real
|
|
// credentials. Placeholder values from .env.example do not count, so
|
|
// self-hosted installs default to email/password only.
|
|
func oauthProviderConfigured(provider string) bool {
|
|
id := strings.TrimSpace(os.Getenv(provider + "_CLIENT_ID"))
|
|
secret := strings.TrimSpace(os.Getenv(provider + "_CLIENT_SECRET"))
|
|
return id != "" && secret != "" &&
|
|
!strings.HasPrefix(id, "PLACEHOLDER_") && !strings.HasPrefix(secret, "PLACEHOLDER_")
|
|
}
|
|
|
|
type betterAuthLoginUser struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
Image string `json:"image"`
|
|
}
|
|
|
|
type betterAuthLoginResponse struct {
|
|
User betterAuthLoginUser `json:"user"`
|
|
}
|
|
|
|
func verifyBetterAuthCredentials(c *gin.Context, email string, password string) (betterAuthLoginUser, bool) {
|
|
endpoint, err := betterAuthSignInURL()
|
|
if err != nil {
|
|
log.Printf("Better Auth fallback disabled: %v", err)
|
|
return betterAuthLoginUser{}, false
|
|
}
|
|
|
|
body, err := json.Marshal(gin.H{
|
|
"email": strings.TrimSpace(email),
|
|
"password": password,
|
|
})
|
|
if err != nil {
|
|
return betterAuthLoginUser{}, false
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
|
if err != nil {
|
|
return betterAuthLoginUser{}, false
|
|
}
|
|
request.Header.Set("Content-Type", "application/json")
|
|
request.Header.Set("Accept", "application/json")
|
|
|
|
client := &http.Client{Timeout: 5 * time.Second}
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
log.Printf("Better Auth fallback failed: %v", err)
|
|
return betterAuthLoginUser{}, false
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
|
return betterAuthLoginUser{}, false
|
|
}
|
|
|
|
var payload betterAuthLoginResponse
|
|
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
|
log.Printf("Better Auth fallback response decode failed: %v", err)
|
|
return betterAuthLoginUser{}, false
|
|
}
|
|
|
|
payload.User.Email = strings.ToLower(strings.TrimSpace(firstNonEmpty(payload.User.Email, email)))
|
|
payload.User.Name = strings.TrimSpace(payload.User.Name)
|
|
if payload.User.Name == "" {
|
|
payload.User.Name = "Containr User"
|
|
}
|
|
|
|
if payload.User.Email == "" {
|
|
return betterAuthLoginUser{}, false
|
|
}
|
|
|
|
return payload.User, true
|
|
}
|
|
|
|
func betterAuthSignInURL() (string, error) {
|
|
rawTarget := strings.TrimSpace(os.Getenv("BETTER_AUTH_PROXY_URL"))
|
|
if rawTarget == "" {
|
|
rawTarget = "http://127.0.0.1:3001"
|
|
}
|
|
|
|
target, err := parseAuthProxyTarget(rawTarget)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return target.ResolveReference(&url.URL{Path: "/api/auth/sign-in/email"}).String(), nil
|
|
}
|
|
|
|
func betterAuthSignUpURL() (string, error) {
|
|
rawTarget := strings.TrimSpace(os.Getenv("BETTER_AUTH_PROXY_URL"))
|
|
if rawTarget == "" {
|
|
rawTarget = "http://127.0.0.1:3001"
|
|
}
|
|
|
|
target, err := parseAuthProxyTarget(rawTarget)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return target.ResolveReference(&url.URL{Path: "/api/auth/sign-up/email"}).String(), nil
|
|
}
|
|
|
|
func createLocalUserFromBetterAuth(db *database.DB, authUser betterAuthLoginUser) (User, error) {
|
|
email := strings.ToLower(strings.TrimSpace(authUser.Email))
|
|
if email == "" {
|
|
return User{}, errors.New("email is required")
|
|
}
|
|
|
|
name := strings.TrimSpace(authUser.Name)
|
|
if name == "" {
|
|
name = "Containr User"
|
|
}
|
|
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(uuid.NewString()), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return User{}, err
|
|
}
|
|
|
|
// A mirrored first account owns the platform, same as handleRegister.
|
|
var total int
|
|
if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&total); err != nil {
|
|
return User{}, err
|
|
}
|
|
|
|
var user User
|
|
err = db.QueryRow(`
|
|
INSERT INTO users (email, password_hash, name, avatar_url, is_admin)
|
|
VALUES ($1, $2, $3, NULLIF($4, ''), $5)
|
|
ON CONFLICT (email) DO UPDATE
|
|
SET name = EXCLUDED.name,
|
|
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
|
|
updated_at = NOW()
|
|
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
|
|
`, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image), total == 0).
|
|
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
|
|
if err != nil {
|
|
return User{}, err
|
|
}
|
|
|
|
return user, nil
|
|
}
|
|
|
|
func handleRegister(c *gin.Context) {
|
|
var req RegisterRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
db := c.MustGet("db").(*database.DB)
|
|
jwtSecret := c.MustGet("jwt_secret").(string)
|
|
|
|
total, err := countLocalUsers(db)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
return
|
|
}
|
|
|
|
// Closed once the first account exists unless the owner reopens registration.
|
|
if total > 0 && !signupEnabled(db) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"})
|
|
return
|
|
}
|
|
|
|
var existing int
|
|
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&existing)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
return
|
|
}
|
|
|
|
if existing > 0 {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
|
return
|
|
}
|
|
|
|
// Hash password
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
|
return
|
|
}
|
|
|
|
// First account owns the platform.
|
|
var user User
|
|
err = db.QueryRow(`
|
|
INSERT INTO users (email, password_hash, name, is_admin)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
|
|
`, req.Email, string(hashedPassword), req.Name, total == 0).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
|
|
return
|
|
}
|
|
|
|
// Generate JWT token
|
|
token, err := generateJWT(user.ID, user.Email, jwtSecret)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, AuthResponse{
|
|
Token: token,
|
|
User: user,
|
|
})
|
|
}
|
|
|
|
func handleCreateUser(c *gin.Context) {
|
|
db, ok := requireAdmin(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req ManualUserCreateRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
if req.Email == "" || req.Name == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Email and name are required"})
|
|
return
|
|
}
|
|
|
|
var existing int
|
|
if err := db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", req.Email).Scan(&existing); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
return
|
|
}
|
|
if existing > 0 {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
|
return
|
|
}
|
|
|
|
if err := createBetterAuthUser(c, req.Name, req.Email, req.Password); err != nil {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, errBetterAuthUserExists) {
|
|
status = http.StatusConflict
|
|
}
|
|
c.JSON(status, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
|
return
|
|
}
|
|
|
|
var user User
|
|
err = db.QueryRow(`
|
|
INSERT INTO users (email, password_hash, name)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
|
|
`, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
|
|
if err != nil {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, gin.H{"user": user})
|
|
}
|
|
|
|
var errBetterAuthUserExists = errors.New("User already exists")
|
|
|
|
func createBetterAuthUser(c *gin.Context, name string, email string, password string) error {
|
|
endpoint, err := betterAuthSignUpURL()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
body, err := json.Marshal(gin.H{
|
|
"name": name,
|
|
"email": email,
|
|
"password": password,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
request, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint, bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
request.Header.Set("Content-Type", "application/json")
|
|
request.Header.Set("Accept", "application/json")
|
|
if token := strings.TrimSpace(os.Getenv("BETTER_AUTH_INTERNAL_TOKEN")); token != "" {
|
|
request.Header.Set("X-Containr-Auth-Internal", token)
|
|
}
|
|
|
|
response, err := (&http.Client{Timeout: 5 * time.Second}).Do(request)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer response.Body.Close()
|
|
|
|
if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices {
|
|
return nil
|
|
}
|
|
|
|
var payload map[string]interface{}
|
|
_ = json.NewDecoder(response.Body).Decode(&payload)
|
|
message := "Failed to create auth user"
|
|
if rawMessage, ok := payload["message"].(string); ok && strings.TrimSpace(rawMessage) != "" {
|
|
message = rawMessage
|
|
} else if rawError, ok := payload["error"].(string); ok && strings.TrimSpace(rawError) != "" {
|
|
message = rawError
|
|
}
|
|
|
|
if response.StatusCode == http.StatusConflict || strings.Contains(strings.ToLower(message), "already") {
|
|
return errBetterAuthUserExists
|
|
}
|
|
|
|
return errors.New(message)
|
|
}
|
|
|
|
func countLocalUsers(db *database.DB) (int, error) {
|
|
var count int
|
|
if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
|
|
return 0, err
|
|
}
|
|
// Better Auth writes to auth_users; a row there only mirrors into users on
|
|
// the first authenticated call. Count it too or a second public sign-up
|
|
// slips through the bootstrap window. UNION by email avoids double-counting
|
|
// mirrored users.
|
|
var authTable *string
|
|
if err := db.QueryRow("SELECT to_regclass('auth_users')").Scan(&authTable); err != nil {
|
|
return 0, err
|
|
}
|
|
if authTable != nil && *authTable != "" {
|
|
if err := db.QueryRow(`
|
|
SELECT COUNT(*) FROM (
|
|
SELECT email FROM users
|
|
UNION
|
|
SELECT email FROM auth_users
|
|
) u
|
|
`).Scan(&count); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
func handleGetProfile(c *gin.Context) {
|
|
userID := c.MustGet("user_id").(string)
|
|
db := c.MustGet("db").(*database.DB)
|
|
|
|
var user User
|
|
err := db.QueryRow(`
|
|
SELECT id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
|
|
FROM users
|
|
WHERE id = $1
|
|
`, userID).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
|
|
|
|
if err == sql.ErrNoRows {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
|
|
return
|
|
} else if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, user)
|
|
}
|
|
|
|
func handleUpdateProfile(c *gin.Context) {
|
|
userID := c.MustGet("user_id").(string)
|
|
db := c.MustGet("db").(*database.DB)
|
|
|
|
var req struct {
|
|
Name string `json:"name,omitempty"`
|
|
AvatarURL string `json:"avatar_url,omitempty"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Update user profile
|
|
_, err := db.Exec(`
|
|
UPDATE users
|
|
SET name = COALESCE($1, name), avatar_url = COALESCE($2, avatar_url)
|
|
WHERE id = $3
|
|
`, req.Name, req.AvatarURL, userID)
|
|
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile"})
|
|
return
|
|
}
|
|
|
|
// Return updated user
|
|
handleGetProfile(c)
|
|
}
|
|
|
|
func generateJWT(userID, email, secret string) (string, error) {
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
|
"user_id": userID,
|
|
"email": email,
|
|
"exp": time.Now().Add(time.Hour * 24 * 7).Unix(), // 7 days
|
|
})
|
|
|
|
return token.SignedString([]byte(secret))
|
|
}
|
|
|
|
// generateImpersonationJWT mints a short-lived token acting as `userID`,
|
|
// tagged with the admin who minted it so downstream callers can attribute
|
|
// actions taken while impersonating.
|
|
func generateImpersonationJWT(userID, email, actorID, secret string, expiresAt time.Time) (string, error) {
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
|
"user_id": userID,
|
|
"email": email,
|
|
"impersonated_by": actorID,
|
|
"exp": expiresAt.Unix(),
|
|
})
|
|
return token.SignedString([]byte(secret))
|
|
}
|
|
|
|
// ValidateJWT validates a JWT token and returns the claims
|
|
func ValidateJWT(tokenString, secret string) (jwt.MapClaims, error) {
|
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, jwt.ErrSignatureInvalid
|
|
}
|
|
return []byte(secret), nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
|
return claims, nil
|
|
}
|
|
|
|
return nil, jwt.ErrInvalidKey
|
|
}
|