添加 OIDC 认证支持,更新环境变量配置,重构 Docker Compose 文件,移除旧的 Dex 初始化脚本,优化用户模型,更新前端登录流程,支持通过 OIDC 登录。

This commit is contained in:
Yuzhong Zhang
2025-08-18 20:50:43 +08:00
parent fa80805bb1
commit 4da39f2d6a
12 changed files with 233 additions and 179 deletions
+13 -1
View File
@@ -1,8 +1,20 @@
# GitHub OAuth 配置
GITHUB_CLIENT_ID="xxxxxxxxxxxxxxxxxxx" GITHUB_CLIENT_ID="xxxxxxxxxxxxxxxxxxx"
GITHUB_CLIENT_SECRET="xxxxxxxxxxxxxx" GITHUB_CLIENT_SECRET="xxxxxxxxxxxxxx"
GITHUB_REDIRECT_URL="http://localhost:3000/auth/github/callback" # 或者你部署后的回调地址 GITHUB_REDIRECT_URL="http://localhost:3002/auth/github/callback" # 或者你部署后的回调地址
# OIDC 配置
OIDC_ISSUER_URL="http://localhost:5556/.well-known/openid-configuration" # OIDC 提供商地址
OIDC_CLIENT_ID="excalidraw"
OIDC_CLIENT_SECRET="excalidraw-secret"
OIDC_REDIRECT_URL="http://localhost:3002/auth/oidc/callback"
# JWT 配置
JWT_SECRET="YOUR_SUPER_SECRET_RANDOM_STRING" JWT_SECRET="YOUR_SUPER_SECRET_RANDOM_STRING"
# OpenAI 配置
OPENAI_API_KEY=sk-xxxxxxxxxxxxxx OPENAI_API_KEY=sk-xxxxxxxxxxxxxx
OPENAI_BASE_URL=https://xxxxxx.xxxxx # 不加/v1... OPENAI_BASE_URL=https://xxxxxx.xxxxx # 不加/v1...
# 存储配置
STORAGE_TYPE=sqlite # 支持memoryfilesystem, kv, s3,具体看README STORAGE_TYPE=sqlite # 支持memoryfilesystem, kv, s3,具体看README
+28
View File
@@ -0,0 +1,28 @@
issuer: http://localhost:5556
storage:
type: memory
web:
http: 0.0.0.0:5556
allowedOrigins: ["*"]
logger:
level: debug
format: text
enablePasswordDB: true
staticClients:
- id: excalidraw
redirectURIs:
- {{ .Env.OIDC_REDIRECT_URL }}
name: Excalidraw
public: true
secret: excalidraw-secret
staticPasswords:
- email: {{ .Env.ADMIN_EMAIL }}
hash: {{ .Env.ADMIN_PASSWORD_HASH }}
username: {{ .Env.ADMIN_USERNAME }}
userID: {{ .Env.ADMIN_USER_ID }}
-29
View File
@@ -1,29 +0,0 @@
issuer: http://localhost:5556
storage:
type: sqlite3
config:
file: /var/lib/dex/dex.db
web:
http: 0.0.0.0:5556
allowedOrigins: ["*"]
logger:
level: debug
format: text
enablePasswordDB: true
staticClients:
- id: excalidraw
redirectURIs:
- http://localhost:3002/auth/oidc/callback
name: Excalidraw
secret: ${OIDC_CLIENT_SECRET:-excalidraw-secret}
staticPasswords:
- email: ${ADMIN_EMAIL:-admin@example.com}
hash: ${ADMIN_PASSWORD_HASH}
username: ${ADMIN_USERNAME:-admin}
userID: "admin-001"
+3 -2
View File
@@ -5,8 +5,9 @@ import "time"
type ( type (
User struct { User struct {
ID uint `json:"id" gorm:"primarykey"` ID uint `json:"id" gorm:"primarykey"`
GitHubID int64 `json:"githubId" gorm:"unique"` Subject string `json:"subject" gorm:"uniqueIndex"`
Login string `json:"login"` Login string `json:"login" gorm:"uniqueIndex"`
Email string `json:"email"`
AvatarURL string `json:"avatarUrl"` AvatarURL string `json:"avatarUrl"`
Name string `json:"name"` Name string `json:"name"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
+19 -9
View File
@@ -8,20 +8,30 @@ services:
ports: ports:
- "5556:5556" - "5556:5556"
volumes: volumes:
- ./config/dex.config.yml:/etc/dex/config.yml - ./config/dex.config.yaml:/etc/dex/config.yaml
- dex-data:/var/lib/dex
environment: environment:
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID} - OIDC_REDIRECT_URL=${OIDC_REDIRECT_URL:-http://localhost:3000/auth/oidc/callback}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-excalidraw-secret} - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-excalidraw-secret}
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-excalidraw}
- OIDC_ISSUER=${OIDC_ISSUER:-http://localhost:5556}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH:-your_secure_password}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com}
- ADMIN_USER_ID=${ADMIN_USER_ID:-'admin1234'}
command: ["dex", "serve", "/etc/dex/config.yaml"]
networks: networks:
- dex-network - dex-network
volumes: excalidraw:
dex-data: build:
context: .
dockerfile: excalidraw-complete.Dockerfile
ports:
- "3003:3002"
volumes:
- ./data:/root/data
- ./excalidraw.db:/root/excalidraw.db:Z
- ./.env:/root/.env
networks: networks:
dex-network: dex-network:
+2
View File
@@ -39,7 +39,9 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.3 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.28.5 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.28.5 // indirect
github.com/aws/smithy-go v1.20.1 // indirect github.com/aws/smithy-go v1.20.1 // indirect
github.com/coreos/go-oidc/v3 v3.15.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
+4
View File
@@ -38,6 +38,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.28.5 h1:J/PpTf/hllOjx8Xu9DMflff3Fajf
github.com/aws/aws-sdk-go-v2/service/sts v1.28.5/go.mod h1:0ih0Z83YDH/QeQ6Ori2yGE2XvWYv/Xm+cZc01LC6oK0= github.com/aws/aws-sdk-go-v2/service/sts v1.28.5/go.mod h1:0ih0Z83YDH/QeQ6Ori2yGE2XvWYv/Xm+cZc01LC6oK0=
github.com/aws/smithy-go v1.20.1 h1:4SZlSlMr36UEqC7XOyRVb27XMeZubNcBNN+9IgEPIQw= github.com/aws/smithy-go v1.20.1 h1:4SZlSlMr36UEqC7XOyRVb27XMeZubNcBNN+9IgEPIQw=
github.com/aws/smithy-go v1.20.1/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= github.com/aws/smithy-go v1.20.1/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -51,6 +53,8 @@ github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
+155
View File
@@ -0,0 +1,155 @@
package auth
import (
"context"
"excalidraw-complete/core"
"fmt"
"net/http"
"os"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt/v5"
"github.com/sirupsen/logrus"
"golang.org/x/oauth2"
)
var (
oidcOauthConfig *oauth2.Config
oidcProvider *oidc.Provider
verifier *oidc.IDTokenVerifier
)
// OIDCClaims represents the claims from OIDC token
type OIDCClaims struct {
Email string `json:"email"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
Picture string `json:"picture"`
Sub string `json:"sub"`
}
func InitDex() {
providerURL := os.Getenv("OIDC_ISSUER_URL")
clientID := os.Getenv("OIDC_CLIENT_ID")
clientSecret := os.Getenv("OIDC_CLIENT_SECRET")
redirectURL := os.Getenv("OIDC_REDIRECT_URL")
if providerURL == "" || clientID == "" || clientSecret == "" {
logrus.Warn("OIDC credentials are not set. OIDC authentication routes will not work.")
return
}
var err error
oidcProvider, err = oidc.NewProvider(context.Background(), providerURL)
if err != nil {
logrus.Errorf("Failed to create OIDC provider: %s", err.Error())
return
}
oidcOauthConfig = &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
Endpoint: oidcProvider.Endpoint(),
}
logrus.Info("OIDC provider initialized")
verifier = oidcProvider.Verifier(&oidc.Config{
ClientID: clientID,
})
}
func HandleOIDCLogin(w http.ResponseWriter, r *http.Request) {
if oidcOauthConfig == nil {
http.Error(w, "OIDC is not configured", http.StatusInternalServerError)
return
}
url := oidcOauthConfig.AuthCodeURL("random", oauth2.AccessTypeOffline)
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}
func HandleOIDCCallback(w http.ResponseWriter, r *http.Request) {
if oidcOauthConfig == nil {
http.Error(w, "OIDC is not configured", http.StatusInternalServerError)
return
}
code := r.FormValue("code")
if code == "" {
logrus.Error("no code in callback")
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
token, err := oidcOauthConfig.Exchange(context.Background(), code)
if err != nil {
logrus.Errorf("failed to exchange token: %s", err.Error())
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
logrus.Error("no id_token in token response")
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
idToken, err := verifier.Verify(context.Background(), rawIDToken)
if err != nil {
logrus.Errorf("failed to verify ID token: %s", err.Error())
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
var claims OIDCClaims
if err := idToken.Claims(&claims); err != nil {
logrus.Errorf("failed to extract claims from ID token: %s", err.Error())
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
// Create user from OIDC claims
user := &core.User{
Subject: claims.Sub,
Login: claims.PreferredUsername,
Email: claims.Email,
AvatarURL: claims.Picture,
Name: claims.Name,
}
// If preferred_username is not available, use email
if user.Login == "" && user.Email != "" {
user.Login = user.Email
}
jwtToken, err := createOIDCJWT(user)
if err != nil {
logrus.Errorf("failed to create JWT: %s", err.Error())
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
return
}
// Redirect to frontend with token
http.Redirect(w, r, fmt.Sprintf("/?token=%s", jwtToken), http.StatusTemporaryRedirect)
}
func createOIDCJWT(user *core.User) (string, error) {
claims := AppClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: user.Subject,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 7)), // 1 week
IssuedAt: jwt.NewNumericDate(time.Now()),
},
Login: user.Login,
AvatarURL: user.AvatarURL,
Name: user.Name,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
}
+3 -5
View File
@@ -23,7 +23,6 @@ var (
jwtSecret []byte jwtSecret []byte
) )
const oauthStateString = "random"
// AppClaims represents the custom claims for the JWT. // AppClaims represents the custom claims for the JWT.
type AppClaims struct { type AppClaims struct {
@@ -124,10 +123,9 @@ func HandleGitHubCallback(w http.ResponseWriter, r *http.Request) {
return return
} }
// For now we don't have a user database, so we create a user object on the fly. // Create user object using Subject instead of GitHubID
// In phase 3, we will save/get the user from the database here.
user := &core.User{ user := &core.User{
GitHubID: githubUser.ID, Subject: fmt.Sprintf("github:%d", githubUser.ID),
Login: githubUser.Login, Login: githubUser.Login,
AvatarURL: githubUser.AvatarURL, AvatarURL: githubUser.AvatarURL,
Name: githubUser.Name, Name: githubUser.Name,
@@ -147,7 +145,7 @@ func HandleGitHubCallback(w http.ResponseWriter, r *http.Request) {
func createJWT(user *core.User) (string, error) { func createJWT(user *core.User) (string, error) {
claims := AppClaims{ claims := AppClaims{
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
Subject: fmt.Sprintf("%d", user.GitHubID), Subject: user.Subject,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 7)), // 1 week ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 7)), // 1 week
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
}, },
+6
View File
@@ -171,6 +171,11 @@ func setupRouter(store stores.Store) *chi.Mux {
r.Get("/callback", auth.HandleGitHubCallback) r.Get("/callback", auth.HandleGitHubCallback)
}) })
r.Route("/auth/oidc", func(r chi.Router) {
r.Get("/login", auth.HandleOIDCLogin)
r.Get("/callback", auth.HandleOIDCCallback)
})
return r return r
} }
@@ -307,6 +312,7 @@ func main() {
}) })
auth.Init() auth.Init()
auth.InitDex()
openai.Init() openai.Init()
store := stores.GetStore() store := stores.GetStore()
-64
View File
@@ -1,64 +0,0 @@
#!/bin/bash
set -e
# 检查环境变量
if [ -z "$ADMIN_PASSWORD" ]; then
echo "错误: 请设置 ADMIN_PASSWORD 环境变量"
exit 1
fi
# 生成密码哈希
echo "正在生成密码哈希..."
PASSWORD_HASH=$(docker run --rm dexidp/dex:v2.38.0 hash --password="$ADMIN_PASSWORD")
# 创建临时配置文件
cat > /tmp/dex-init-config.yml << EOF
issuer: http://localhost:5556
storage:
type: sqlite3
config:
file: /var/lib/dex/dex.db
web:
http: 0.0.0.0:5556
logger:
level: info
enablePasswordDB: true
staticPasswords:
- email: ${ADMIN_EMAIL:-admin@example.com}
hash: $PASSWORD_HASH
username: ${ADMIN_USERNAME:-admin}
userID: "admin-001"
EOF
# 初始化 Dex 数据库
echo "正在初始化 Dex 数据库..."
docker run --rm \
-v $(pwd)/config/dex.config.yml:/etc/dex/config.yml \
-v dex-data:/var/lib/dex \
dexidp/dex:v2.38.0 \
serve /etc/dex/config.yml &
DEX_PID=$!
# 等待 Dex 启动
echo "等待 Dex 启动..."
sleep 10
# 停止临时 Dex 进程
kill $DEX_PID 2>/dev/null || true
echo "Dex 用户初始化完成!"
echo "管理员账户:"
echo " 用户名: ${ADMIN_USERNAME:-admin}"
echo " 邮箱: ${ADMIN_EMAIL:-admin@example.com}"
echo " 密码: $ADMIN_PASSWORD"
echo ""
echo "请使用以下凭据登录:"
echo " Dex UI: http://localhost:5556"
echo " 用户名: ${ADMIN_USERNAME:-admin}"
echo " 密码: $ADMIN_PASSWORD"
-69
View File
@@ -1,69 +0,0 @@
#!/bin/bash
# 生成随机密码
generate_password() {
openssl rand -base64 16 | tr -d "=+/" | cut -c1-16
}
# 生成 JWT 密钥
generate_jwt_secret() {
openssl rand -base64 32 | tr -d "=+/" | cut -c1-32
}
echo "正在生成环境变量配置..."
# 生成随机密码和密钥
ADMIN_PASSWORD=$(generate_password)
JWT_SECRET=$(generate_jwt_secret)
OIDC_CLIENT_SECRET=$(generate_password)
# 创建 .env 文件
cat > .env << EOF
# === 认证配置 ===
AUTH_TYPE=oidc
# GitHub OAuth 配置 (可选)
# GITHUB_CLIENT_ID=your_github_client_id
# GITHUB_CLIENT_SECRET=your_github_client_secret
# GITHUB_REDIRECT_URL=http://localhost:3002/auth/github/callback
# OIDC 配置
OIDC_ISSUER_URL=http://localhost:5556/.well-known/openid-configuration
OIDC_CLIENT_ID=excalidraw
OIDC_CLIENT_SECRET=$OIDC_CLIENT_SECRET
OIDC_REDIRECT_URL=http://localhost:3002/auth/oidc/callback
# Dex 配置
OIDC_CLIENT_SECRET=$OIDC_CLIENT_SECRET
ADMIN_USERNAME=admin
ADMIN_PASSWORD=$ADMIN_PASSWORD
ADMIN_EMAIL=admin@example.com
# === JWT 配置 ===
JWT_SECRET=$JWT_SECRET
# === 存储配置 ===
STORAGE_TYPE=sqlite
DATA_SOURCE_NAME=excalidraw.db
LOCAL_STORAGE_PATH=./data
# === 应用配置 ===
LISTEN=:3002
LOG_LEVEL=info
# === OpenAI 配置 (可选) ===
# OPENAI_API_KEY=sk-your_openai_api_key
# OPENAI_BASE_URL=https://api.openai.com
EOF
echo "环境变量配置已生成到 .env 文件"
echo ""
echo "重要信息请保存:"
echo " 管理员密码: $ADMIN_PASSWORD"
echo " JWT 密钥: $JWT_SECRET"
echo " Dex 客户端密钥: $OIDC_CLIENT_SECRET"
echo ""
echo "请运行以下命令启动服务:"
echo " 1. docker-compose -f docker-compose.dex.yml up -d"
echo " 2. ./scripts/init-dex-users.sh"
echo " 3. docker-compose up -d"