做统一认证登录

This commit is contained in:
2026-09-19 21:44:04 +08:00
parent 1a4471e34d
commit fa281363fb
78 changed files with 8127 additions and 1442 deletions
+375
View File
@@ -0,0 +1,375 @@
package auth
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"strings"
authsvc "server/services/auth"
"server/models"
"server/pkg/jwtutil"
beego "github.com/beego/beego/v2/server/web"
"github.com/beego/beego/v2/server/web/context"
)
// AuthLoginController 认证中心登录相关端点
type AuthLoginController struct {
beego.Controller
}
func (c *AuthLoginController) serveJSON(data map[string]interface{}) {
c.Data["json"] = data
_ = c.ServeJSON()
}
// LoginPage 统一登录页(beego 模板渲染)
// GET /auth/login?redirect=<base64(回跳URL)>&client_id=xxx&step=tenant
func (c *AuthLoginController) LoginPage() {
clientID := strings.TrimSpace(c.GetString("client_id"))
redirect := c.GetString("redirect")
step := c.GetString("step")
errMsg := c.GetString("error")
// 已登录且已选企业:直接放行到 authorize,无需再输密码
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
if sid != "" && step != "tenant" {
if session, err := authsvc.GetSession(sid); err == nil && session.Tid != authsvc.PendingTenantID {
if back := decodeRedirect(redirect); back != "" {
c.Redirect(back, 302)
return
}
}
}
// 企业选择步骤:需要展示可进入的企业列表
tenants := make([]authsvc.TenantOption, 0)
if sid != "" {
if session, err := authsvc.GetSession(sid); err == nil {
list, _ := authsvc.ListTenantOptions(session.IdentityID)
tenants = list
// 只有一家企业直接进入,无需展示选择页
if len(tenants) == 1 && step == "tenant" {
if _, _, err := authsvc.ChooseTenant(sid, tenants[0].Tid, clientID); err == nil {
if back := decodeRedirect(redirect); back != "" {
c.Redirect(back, 302)
return
}
}
}
}
}
c.Data["ClientID"] = clientID
c.Data["Redirect"] = redirect
c.Data["Step"] = step
c.Data["Error"] = errMsg
c.Data["Tenants"] = tenants
c.TplName = "auth/login.tpl"
}
// LoginSubmit 提交登录
// POST /auth/login { account, password, client_id, redirect }
func (c *AuthLoginController) LoginSubmit() {
var req struct {
Account string `json:"account"`
Password string `json:"password"`
ClientID string `json:"client_id"`
Redirect string `json:"redirect"`
DeviceID string `json:"device_id"`
}
body := c.Ctx.Input.RequestBody
if len(body) == 0 {
var err error
body, err = io.ReadAll(c.Ctx.Request.Body)
if err != nil || len(body) == 0 {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
return
}
}
if err := json.Unmarshal(body, &req); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
return
}
if strings.TrimSpace(req.Account) == "" || req.Password == "" {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "请输入账号和密码"})
return
}
clientIP := c.Ctx.Input.IP()
userAgent := c.Ctx.Request.UserAgent()
result, err := authsvc.PasswordLogin(req.Account, req.Password, req.ClientID, clientIP, userAgent)
if err != nil {
c.serveJSON(map[string]interface{}{"code": 401, "msg": err.Error()})
return
}
// 写入认证中心会话 Cookie,供后续 authorize 识别登录态
setSessionCookieForCtx(c.Ctx, result.Tokens.Sid)
if result.NeedChooseTenant {
c.serveJSON(map[string]interface{}{
"code": 200,
"msg": "请选择要进入的企业",
"need_choose_tenant": true,
"tenants": result.Tenants,
"sid": result.Tokens.Sid,
})
return
}
back := decodeRedirect(req.Redirect)
c.serveJSON(map[string]interface{}{
"code": 200,
"msg": "登录成功",
"redirect": back,
"tokens": result.Tokens,
"tenants": result.Tenants,
})
}
// Tenants 当前登录身份可进入的企业列表(Bearer Token)
// GET /auth/tenants
func (c *AuthLoginController) Tenants() {
claims := claimsFromHeaderLogin(c)
if claims == nil {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
return
}
list, err := authsvc.ListTenantOptions(uint64(claims.UserID))
if err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "查询失败"})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "data": list})
}
// SwitchTenant 免密切换企业
// POST /auth/switch-tenant { tid, client_id, redirect }
func (c *AuthLoginController) SwitchTenant() {
var req struct {
Tid uint64 `json:"tid"`
ClientID string `json:"client_id"`
Redirect string `json:"redirect"`
}
body := c.Ctx.Input.RequestBody
if len(body) == 0 {
body, _ = io.ReadAll(c.Ctx.Request.Body)
}
_ = json.Unmarshal(body, &req)
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
if sid == "" {
if claims := claimsFromHeaderLogin(c); claims != nil {
sid = claims.Sid
}
}
if sid == "" || req.Tid == 0 {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
return
}
tokens, profile, err := authsvc.ChooseTenant(sid, req.Tid, req.ClientID)
if err != nil {
c.serveJSON(map[string]interface{}{"code": 403, "msg": err.Error()})
return
}
setSessionCookieForCtx(c.Ctx, tokens.Sid)
c.serveJSON(map[string]interface{}{
"code": 200,
"msg": "切换成功",
"redirect": decodeRedirect(req.Redirect),
"data": map[string]interface{}{"tokens": tokens, "identity": profile},
})
}
// LogoutPage 单点登出入口(OIDC end_session_endpoint)
// GET /auth/logout?post_logout_redirect_uri=&client_id=
//
// 清除认证中心 Cookie 并吊销会话与令牌;回跳地址必须在应用白名单内。
// 登记了 backchannel_logout_uri 的应用会收到服务端登出通知(P2 阶段接入)。
func (c *AuthLoginController) LogoutPage() {
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
access := bearerTokenLogin(c)
if sid != "" {
_ = authsvc.RevokeSession(sid, models.RevokeReasonLogout)
}
if access != "" {
_ = authsvc.RevokeTokenPair("", access, models.RevokeReasonLogout)
}
clearSessionCookieForCtx(c.Ctx)
back := strings.TrimSpace(c.GetString("post_logout_redirect_uri"))
cid := strings.TrimSpace(c.GetString("client_id"))
if back != "" && cid != "" {
if client, err := findClient(cid); err == nil && allowRedirect(client, back) {
c.Redirect(back, 302)
return
}
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已登出"})
}
// Sessions 在线设备列表
// GET /auth/sessions
func (c *AuthLoginController) Sessions() {
claims := claimsFromHeaderLogin(c)
if claims == nil {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
return
}
list, err := authsvc.ListActiveSessions(uint64(claims.UserID))
if err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "查询失败"})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "data": list})
}
// KickSession 踢下线指定设备
// POST /auth/sessions/kick { sid }
func (c *AuthLoginController) KickSession() {
var req struct {
Sid string `json:"sid"`
}
body := c.Ctx.Input.RequestBody
if len(body) == 0 {
body, _ = io.ReadAll(c.Ctx.Request.Body)
}
_ = json.Unmarshal(body, &req)
claims := claimsFromHeaderLogin(c)
if claims == nil {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
return
}
if req.Sid == "" {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
return
}
// 只允许踢自己名下的会话
owned := false
if list, err := authsvc.ListActiveSessions(uint64(claims.UserID)); err == nil {
for _, s := range list {
if s.Sid == req.Sid {
owned = true
break
}
}
}
if !owned {
c.serveJSON(map[string]interface{}{"code": 403, "msg": "无权操作"})
return
}
if err := authsvc.RevokeSession(req.Sid, models.RevokeReasonAdmin); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "操作失败"})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已下线"})
}
// LogoutAction 退出登录(清 Cookie + 吊销会话与令牌)
// POST /auth/logout
func (c *AuthLoginController) LogoutAction() {
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
access := ""
if claims := claimsFromHeaderLogin(c); claims != nil {
if sid == "" {
sid = claims.Sid
}
access = bearerTokenLogin(c)
}
_ = authsvc.Logout(access, "")
if sid != "" {
_ = authsvc.RevokeSession(sid, models.RevokeReasonLogout)
}
clearSessionCookieForCtx(c.Ctx)
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已登出"})
}
// VerifyConfig 租户登录验证配置(替代 /backend/login/getOpenVerify)
// GET /auth/verify-config?tid=
func (c *AuthLoginController) VerifyConfig() {
tid, _ := c.GetInt64("tid", 0)
cfg := authsvc.GetTenantSessionPolicy(uint64(tid))
var authCfg models.AuthTenantAuthConfig
verifyType := "captcha"
openVerify := 1
if err := models.Orm.QueryTable(new(models.AuthTenantAuthConfig)).
Filter("tid", tid).One(&authCfg); err == nil {
verifyType = authCfg.VerifyType
openVerify = int(authCfg.OpenVerify)
}
c.serveJSON(map[string]interface{}{
"code": 200,
"data": map[string]interface{}{
"openVerify": openVerify,
"verifyType": verifyType,
"sessionTTL": cfg.SessionTTL,
"maxSession": cfg.MaxSession,
"mfaRequired": 0,
},
})
}
// ---------------------------------------------------------------- 工具
func decodeRedirect(encoded string) string {
if encoded == "" {
return ""
}
raw, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return ""
}
back := string(raw)
// 只允许站内地址,防开放重定向
if !strings.HasPrefix(back, "/auth/") && !strings.HasPrefix(back, "http") {
return ""
}
return back
}
func claimsFromHeaderLogin(c *AuthLoginController) *jwtutil.Claims {
token := bearerTokenLogin(c)
if token == "" {
return nil
}
claims, err := jwtutil.ParseToken(token)
if err != nil {
return nil
}
return claims
}
func bearerTokenLogin(c *AuthLoginController) string {
header := c.Ctx.Request.Header.Get("Authorization")
if header == "" {
return ""
}
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return ""
}
return strings.TrimSpace(parts[1])
}
func setSessionCookieForCtx(ctx *context.Context, sid string) {
ctx.Output.Header("Set-Cookie",
fmt.Sprintf("%s=%s; Path=/; Max-Age=%d; HttpOnly; Secure; SameSite=Lax",
sessionCookieName, sid, sessionCookieTTL))
}
func clearSessionCookieForCtx(ctx *context.Context) {
ctx.Output.Header("Set-Cookie",
fmt.Sprintf("%s=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax", sessionCookieName))
}
+526
View File
@@ -0,0 +1,526 @@
// Package auth 统一认证中心(UAC)控制器:api.yunzer.cn/auth
//
// 实现 OIDC 1.0(基于 OAuth 2.1 + PKCE)标准端点,
// 以后每开发一个新软件,只需在 yz_auth_client 注册一条即可接入。
package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
authsvc "server/services/auth"
"server/models"
"server/pkg/jwtutil"
beego "github.com/beego/beego/v2/server/web"
)
// 认证中心会话 Cookie(仅作用于认证中心域名,用于 authorize 阶段识别登录态)
const (
sessionCookieName = "yz_sid"
sessionCookieTTL = 7200
)
// grant_type 常量
const (
GrantAuthCode = "authorization_code"
GrantRefresh = "refresh_token"
ResponseTypeCode = "code"
)
// AuthOidcController OIDC 标准端点
type AuthOidcController struct {
beego.Controller
}
func (c *AuthOidcController) serveJSON(data map[string]interface{}) {
c.Data["json"] = data
_ = c.ServeJSON()
}
func (c *AuthOidcController) fail(status int, msg string) {
c.Ctx.Output.SetStatus(status)
c.serveJSON(map[string]interface{}{"error": msg})
}
// Discovery OIDC 发现文档
// GET /auth/.well-known/openid-configuration
func (c *AuthOidcController) Discovery() {
issuer := jwtutil.Issuer()
if issuer == "" {
issuer = fmt.Sprintf("https://%s/auth", c.Ctx.Request.Host)
}
c.Data["json"] = map[string]interface{}{
"issuer": issuer,
"authorization_endpoint": issuer + "/authorize",
"token_endpoint": issuer + "/token",
"userinfo_endpoint": issuer + "/userinfo",
"introspection_endpoint": issuer + "/introspect",
"revocation_endpoint": issuer + "/revoke",
"end_session_endpoint": issuer + "/logout",
"jwks_uri": issuer + "/jwks.json",
"response_types_supported": []string{"code"},
"grant_types_supported": []string{GrantAuthCode, GrantRefresh},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{jwtutil.AlgRS256, jwtutil.AlgHS256},
"code_challenge_methods_supported": []string{"S256"},
"scopes_supported": []string{"openid", "profile", "tenant"},
}
_ = c.ServeJSON()
}
// JWKS 公钥集合(各应用本地验签用)
// GET /auth/jwks.json
func (c *AuthOidcController) JWKS() {
keys := jwtutil.JWKS()
if keys == nil {
keys = []jwtutil.JWK{}
}
c.Data["json"] = map[string]interface{}{"keys": keys}
_ = c.ServeJSON()
}
// Authorize 授权端点
// GET /auth/authorize?client_id=&redirect_uri=&response_type=code&scope=&state=&code_challenge=&code_challenge_method=S256
//
// 未登录时重定向到统一登录页,登录后再回到本端点完成授权。
func (c *AuthOidcController) Authorize() {
clientID := strings.TrimSpace(c.GetString("client_id"))
redirectURI := strings.TrimSpace(c.GetString("redirect_uri"))
responseType := strings.TrimSpace(c.GetString("response_type"))
state := c.GetString("state")
scope := c.GetString("scope")
if scope == "" {
scope = "openid"
}
challenge := c.GetString("code_challenge")
challengeMethod := c.GetString("code_challenge_method")
if challengeMethod == "" {
challengeMethod = "S256"
}
nonce := c.GetString("nonce")
if clientID == "" || redirectURI == "" {
c.Ctx.Output.SetStatus(400)
_, _ = c.Ctx.ResponseWriter.Write([]byte("缺少 client_id 或 redirect_uri"))
return
}
if responseType != ResponseTypeCode {
c.Ctx.Output.SetStatus(400)
_, _ = c.Ctx.ResponseWriter.Write([]byte("仅支持 response_type=code"))
return
}
client, err := findClient(clientID)
if err != nil {
c.Ctx.Output.SetStatus(400)
_, _ = c.Ctx.ResponseWriter.Write([]byte("client_id 无效"))
return
}
if !allowRedirect(client, redirectURI) {
c.Ctx.Output.SetStatus(400)
_, _ = c.Ctx.ResponseWriter.Write([]byte("redirect_uri 未登记"))
return
}
// 登录态:Cookie 优先(浏览器跳转),其次 Authorization(服务端调用)
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
if sid == "" {
if claims := claimsFromHeader(c); claims != nil {
sid = claims.Sid
}
}
if sid == "" {
// 未登录 → 去登录页,登录后带着参数回来
back := fmt.Sprintf("%s?%s", authorizePath(c), c.Ctx.Request.URL.RawQuery)
target := fmt.Sprintf("/auth/login?redirect=%s&client_id=%s",
base64.RawURLEncoding.EncodeToString([]byte(back)), clientID)
c.Redirect(target, 302)
return
}
session, err := authsvc.GetSession(sid)
if err != nil {
clearSessionCookie(c)
back := fmt.Sprintf("%s?%s", authorizePath(c), c.Ctx.Request.URL.RawQuery)
target := fmt.Sprintf("/auth/login?redirect=%s&client_id=%s",
base64.RawURLEncoding.EncodeToString([]byte(back)), clientID)
c.Redirect(target, 302)
return
}
// 已登录但未选择企业:跳登录页的企业选择步骤
if session.Tid == authsvc.PendingTenantID {
back := fmt.Sprintf("%s?%s", authorizePath(c), c.Ctx.Request.URL.RawQuery)
target := fmt.Sprintf("/auth/login?step=tenant&redirect=%s&client_id=%s",
base64.RawURLEncoding.EncodeToString([]byte(back)), clientID)
c.Redirect(target, 302)
return
}
code, err := issueAuthCode(client.ClientID, session.IdentityID, session.Tid, redirectURI, challenge, challengeMethod, scope, nonce)
if err != nil {
c.Ctx.Output.SetStatus(500)
_, _ = c.Ctx.ResponseWriter.Write([]byte("签发授权码失败"))
return
}
sep := "?"
if strings.Contains(redirectURI, "?") {
sep = "&"
}
c.Redirect(fmt.Sprintf("%s%scode=%s&state=%s", redirectURI, sep, code, state), 302)
}
// Token 令牌端点
// POST /auth/token
// - grant_type=authorization_code:code + code_verifier(PKCE)+ client_id
// - grant_type=refresh_token:refresh_token + client_id
func (c *AuthOidcController) Token() {
grantType := strings.TrimSpace(c.GetString("grant_type"))
clientID := strings.TrimSpace(c.GetString("client_id"))
if clientID == "" {
// 兼容表单/JSON 以外的取参方式
clientID = strings.TrimSpace(c.Ctx.Request.FormValue("client_id"))
}
if grantType == "" {
grantType = strings.TrimSpace(c.Ctx.Request.FormValue("grant_type"))
}
switch grantType {
case GrantAuthCode:
code := strings.TrimSpace(c.GetString("code"))
verifier := strings.TrimSpace(c.GetString("code_verifier"))
if code == "" || verifier == "" || clientID == "" {
c.fail(400, "invalid_request")
return
}
pair, claims, err := exchangeCode(code, verifier, clientID)
if err != nil {
c.fail(400, err.Error())
return
}
idToken, _ := buildIDToken(claims, clientID)
c.serveJSON(map[string]interface{}{
"access_token": pair.AccessToken,
"refresh_token": pair.RefreshToken,
"token_type": pair.TokenType,
"expires_in": pair.ExpiresIn,
"id_token": idToken,
"sid": pair.Sid,
})
case GrantRefresh:
refresh := strings.TrimSpace(c.GetString("refresh_token"))
if refresh == "" || clientID == "" {
c.fail(400, "invalid_request")
return
}
pair, err := authsvc.RefreshTokens(refresh, clientID)
if err != nil {
c.fail(400, err.Error())
return
}
c.serveJSON(map[string]interface{}{
"access_token": pair.AccessToken,
"refresh_token": pair.RefreshToken,
"token_type": pair.TokenType,
"expires_in": pair.ExpiresIn,
"sid": pair.Sid,
})
default:
c.fail(400, "unsupported_grant_type")
}
}
// UserInfo 用户信息端点,需 Bearer Token
// GET /auth/userinfo
func (c *AuthOidcController) UserInfo() {
claims := claimsFromHeader(c)
if claims == nil {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"error": "invalid_token"})
return
}
if claims.UserID <= 0 {
c.fail(401, "invalid_token")
return
}
var identity models.AuthIdentity
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
Filter("id", claims.UserID).One(&identity); err != nil {
c.fail(401, "invalid_token")
return
}
profile, err := authsvc.BuildProfile(&identity)
if err != nil {
c.fail(500, "server_error")
return
}
c.serveJSON(map[string]interface{}{
"sub": fmt.Sprintf("%d", identity.ID),
"union_id": identity.UnionID,
"tid": claims.TenantId,
"nickname": profile.Nickname,
"mobile": profile.Mobile,
"email": profile.Email,
"avatar": profile.Avatar,
"tenants": profile.Tenants,
"client_id": claims.ClientID,
})
}
// Introspect 令牌校验,供资源服务(各业务后端)调用
// POST /auth/token 之外的独立端点;比本地 JWKS 验签更实时(可查黑名单)
func (c *AuthOidcController) Introspect() {
token := strings.TrimSpace(c.GetString("token"))
if token == "" {
token = strings.TrimSpace(c.Ctx.Request.FormValue("token"))
}
if token == "" {
c.fail(400, "invalid_request")
return
}
claims, err := jwtutil.ParseToken(token)
if err != nil {
c.serveJSON(map[string]interface{}{"active": false})
return
}
if authsvc.IsBlacklisted(claims.ID) {
c.serveJSON(map[string]interface{}{"active": false})
return
}
c.serveJSON(map[string]interface{}{
"active": true,
"sub": claims.Subject,
"user_id": claims.UserID,
"tid": claims.TenantId,
"client_id": claims.ClientID,
"sid": claims.Sid,
"scope": claims.Scope,
"amr": claims.Amr,
"exp": claims.ExpiresAt.Unix(),
})
}
// Revoke 吊销令牌(登出/踢下线)
// POST /auth/revoke
func (c *AuthOidcController) Revoke() {
token := strings.TrimSpace(c.GetString("token"))
refresh := strings.TrimSpace(c.GetString("refresh_token"))
if token == "" {
token = strings.TrimSpace(c.Ctx.Request.FormValue("token"))
}
if token == "" && refresh == "" {
c.fail(400, "invalid_request")
return
}
_ = authsvc.RevokeTokenPair(refresh, token, models.RevokeReasonLogout)
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已吊销"})
}
// ---------------------------------------------------------------- 内部工具
func authorizePath(c *AuthOidcController) string {
return "/auth/authorize"
}
// clearSessionCookie 清除认证中心会话 Cookie
func clearSessionCookie(c *AuthOidcController) {
c.Ctx.Output.Header("Set-Cookie",
fmt.Sprintf("%s=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax", sessionCookieName))
}
// setSessionCookie 写入认证中心会话 Cookie
func setSessionCookie(c *AuthOidcController, sid string) {
c.Ctx.Output.Header("Set-Cookie",
fmt.Sprintf("%s=%s; Path=/; Max-Age=%d; HttpOnly; Secure; SameSite=Lax",
sessionCookieName, sid, sessionCookieTTL))
}
func bearerToken(c *AuthOidcController) string {
header := c.Ctx.Request.Header.Get("Authorization")
if header == "" {
return ""
}
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return ""
}
return strings.TrimSpace(parts[1])
}
func claimsFromHeader(c *AuthOidcController) *jwtutil.Claims {
token := bearerToken(c)
if token == "" {
return nil
}
claims, err := jwtutil.ParseToken(token)
if err != nil {
return nil
}
return claims
}
// findClient 查询启用状态的应用
func findClient(clientID string) (*models.AuthClient, error) {
var client models.AuthClient
err := models.Orm.QueryTable(new(models.AuthClient)).
Filter("client_id", clientID).
Filter("status", 1).
One(&client)
if err != nil {
return nil, err
}
return &client, nil
}
// allowRedirect 校验回跳地址是否在白名单内(精确匹配,防钓鱼)
func allowRedirect(client *models.AuthClient, uri string) bool {
if client.RedirectURIs == nil || *client.RedirectURIs == "" {
return false
}
var list []string
if err := json.Unmarshal([]byte(*client.RedirectURIs), &list); err != nil {
return false
}
for _, item := range list {
if strings.TrimSpace(item) == strings.TrimSpace(uri) {
return true
}
}
return false
}
// issueAuthCode 生成一次性授权码(明文返回,库中只存哈希)
func issueAuthCode(clientID string, identityID, tid uint64, redirectURI, challenge, method, scope, nonce string) (string, error) {
plain, err := randomString(32)
if err != nil {
return "", err
}
sum := sha256.Sum256([]byte(plain))
code := &models.AuthCode{
CodeHash: fmt.Sprintf("%x", sum[:]),
ClientID: clientID,
IdentityID: identityID,
Tid: tid,
RedirectURI: redirectURI,
CodeChallenge: challenge,
CodeChallengeMethod: method,
ExpiresAt: time.Now().Add(60 * time.Second),
}
if scope != "" {
code.Scope = &scope
}
if nonce != "" {
code.Nonce = &nonce
}
if _, err := models.Orm.Insert(code); err != nil {
return "", err
}
return plain, nil
}
// exchangeCode 用授权码换令牌(校验 PKCE、一次性、有效期)
func exchangeCode(code, verifier, clientID string) (*authsvc.TokenPair, *jwtutil.Claims, error) {
sum := sha256.Sum256([]byte(code))
var stored models.AuthCode
if err := models.Orm.QueryTable(new(models.AuthCode)).
Filter("code_hash", fmt.Sprintf("%x", sum[:])).One(&stored); err != nil {
return nil, nil, fmt.Errorf("invalid_grant")
}
if stored.Used != 0 || stored.ExpiresAt.Before(time.Now()) {
return nil, nil, fmt.Errorf("invalid_grant")
}
if stored.ClientID != clientID {
return nil, nil, fmt.Errorf("invalid_client")
}
if !verifyPKCE(verifier, stored.CodeChallenge, stored.CodeChallengeMethod) {
return nil, nil, fmt.Errorf("invalid_grant")
}
// 一次性:立即标记已用
_, _ = models.Orm.QueryTable(new(models.AuthCode)).
Filter("code_hash", stored.CodeHash).
Update(map[string]interface{}{"used": 1})
session, err := authsvc.CreateSession(authsvc.SessionInfo{
IdentityID: stored.IdentityID,
Tid: stored.Tid,
ClientID: clientID,
LoginType: authsvc.LoginTypePassword,
Amr: authsvc.AmrPwd,
})
if err != nil {
return nil, nil, err
}
client, err := findClient(clientID)
accessTTL := 1800
refreshTTL := 2592000
if err == nil {
accessTTL = client.AccessTTL
refreshTTL = client.RefreshTTL
}
pair, err := authsvc.IssueTokens(authsvc.TokenIssue{
IdentityID: stored.IdentityID,
Tid: stored.Tid,
ClientID: clientID,
Sid: session.Sid,
UserType: "tenant",
Amr: authsvc.AmrPwd,
AccessTTL: accessTTL,
RefreshTTL: refreshTTL,
})
if err != nil {
return nil, nil, err
}
claims, err := jwtutil.ParseToken(pair.AccessToken)
if err != nil {
return nil, nil, err
}
return pair, claims, nil
}
// verifyPKCE 校验 PKCE(S256 或 plain)
func verifyPKCE(verifier, challenge, method string) bool {
if challenge == "" {
return false
}
if method == "S256" {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:]) == challenge
}
return verifier == challenge
}
// buildIDToken 生成 OIDC ID Token
func buildIDToken(claims *jwtutil.Claims, clientID string) (string, error) {
return jwtutil.SignToken(jwtutil.TokenOptions{
Alg: jwtutil.AlgRS256,
UserID: claims.UserID,
TenantID: claims.TenantId,
UserType: claims.UserType,
ClientID: clientID,
Sid: claims.Sid,
Subject: claims.Subject,
Audience: []string{clientID},
Amr: claims.Amr,
TTL: time.Hour,
})
}
// randomString 生成随机串
func randomString(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}