作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。 Co-authored-by: Cursor <cursoragent@cursor.com>
635 lines
16 KiB
Go
635 lines
16 KiB
Go
package service
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"crypto/rsa"
|
||
"crypto/sha256"
|
||
"crypto/x509"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"encoding/pem"
|
||
"errors"
|
||
"fmt"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/golang-jwt/jwt/v5"
|
||
"git.iioio.com/freefire/jiang13-forum/config"
|
||
"git.iioio.com/freefire/jiang13-forum/model"
|
||
)
|
||
|
||
const (
|
||
oidcAuthCodeTTL = 5 * time.Minute
|
||
oidcAccessTokenTTL = time.Hour
|
||
oidcIDTokenTTL = time.Hour
|
||
oidcRSABits = 2048
|
||
oidcKeyID = "jiang13-oidc-1"
|
||
)
|
||
|
||
var (
|
||
ErrOIDCNotConfigured = errors.New("OIDC 未配置(请在管理后台启用并至少创建一个 OAuth 应用)")
|
||
ErrOIDCInvalidClient = errors.New("无效的 client_id 或 client_secret")
|
||
ErrOIDCInvalidRedirect = errors.New("redirect_uri 未登记")
|
||
ErrOIDCInvalidRequest = errors.New("授权请求参数无效")
|
||
ErrOIDCInvalidGrant = errors.New("授权码无效或已过期")
|
||
ErrOIDCInvalidToken = errors.New("access_token 无效")
|
||
ErrOIDCUserBanned = errors.New("账号已被禁言,无法授权")
|
||
ErrOIDCPKCEFailed = errors.New("PKCE 校验失败")
|
||
ErrOIDCInvalidLogout = errors.New("post_logout_redirect_uri 未登记")
|
||
)
|
||
|
||
// OIDCService 论坛作为 OpenID Connect Provider
|
||
type OIDCService struct {
|
||
cfg *config.Config
|
||
settings *ForumSettingsService
|
||
|
||
mu sync.RWMutex
|
||
privateKey *rsa.PrivateKey
|
||
}
|
||
|
||
// NewOIDCService 创建并加载/生成 RSA 密钥
|
||
func NewOIDCService(cfg *config.Config, settings *ForumSettingsService) (*OIDCService, error) {
|
||
s := &OIDCService{cfg: cfg, settings: settings}
|
||
if err := s.loadOrCreateKey(); err != nil {
|
||
return nil, err
|
||
}
|
||
return s, nil
|
||
}
|
||
|
||
func (s *OIDCService) runtime() OIDCConfig {
|
||
if s.settings != nil {
|
||
return s.settings.OIDCConfig()
|
||
}
|
||
return OIDCConfig{}
|
||
}
|
||
|
||
func (s *OIDCService) loadOrCreateKey() error {
|
||
keyPath := filepath.Join(s.cfg.DataDir, ".oidc_rsa.pem")
|
||
if data, err := os.ReadFile(keyPath); err == nil && len(data) > 0 {
|
||
block, _ := pem.Decode(data)
|
||
if block == nil {
|
||
return fmt.Errorf("解析 OIDC RSA 密钥失败")
|
||
}
|
||
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||
if err != nil {
|
||
parsed, err2 := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||
if err2 != nil {
|
||
return fmt.Errorf("解析 OIDC RSA 密钥失败: %w", err)
|
||
}
|
||
var ok bool
|
||
key, ok = parsed.(*rsa.PrivateKey)
|
||
if !ok {
|
||
return fmt.Errorf("OIDC 密钥不是 RSA")
|
||
}
|
||
}
|
||
s.privateKey = key
|
||
return nil
|
||
}
|
||
|
||
key, err := rsa.GenerateKey(rand.Reader, oidcRSABits)
|
||
if err != nil {
|
||
return fmt.Errorf("生成 OIDC RSA 密钥失败: %w", err)
|
||
}
|
||
pemBytes := pem.EncodeToMemory(&pem.Block{
|
||
Type: "RSA PRIVATE KEY",
|
||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||
})
|
||
if err := os.WriteFile(keyPath, pemBytes, 0600); err != nil {
|
||
return fmt.Errorf("写入 OIDC RSA 密钥失败: %w", err)
|
||
}
|
||
s.privateKey = key
|
||
return nil
|
||
}
|
||
|
||
// Enabled 是否可对外提供 OIDC
|
||
func (s *OIDCService) Enabled() bool {
|
||
return s.runtime().Ready
|
||
}
|
||
|
||
// Issuer 返回 OIDC issuer
|
||
func (s *OIDCService) Issuer() string {
|
||
return s.runtime().RootURL
|
||
}
|
||
|
||
// Discovery 返回 OpenID Provider Metadata
|
||
func (s *OIDCService) Discovery() (map[string]any, error) {
|
||
rt := s.runtime()
|
||
if !rt.Ready {
|
||
return nil, ErrOIDCNotConfigured
|
||
}
|
||
base := rt.RootURL
|
||
return map[string]any{
|
||
"issuer": base,
|
||
"authorization_endpoint": base + "/oauth/authorize",
|
||
"token_endpoint": base + "/oauth/token",
|
||
"userinfo_endpoint": base + "/oauth/userinfo",
|
||
"jwks_uri": base + "/oauth/jwks",
|
||
"end_session_endpoint": base + "/oauth/logout",
|
||
"response_types_supported": []string{"code"},
|
||
"subject_types_supported": []string{"public"},
|
||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||
"scopes_supported": []string{"openid", "profile", "email", "groups"},
|
||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
|
||
"claims_supported": []string{
|
||
"sub", "name", "preferred_username", "email", "email_verified", "picture", "groups",
|
||
},
|
||
"code_challenge_methods_supported": []string{"S256", "plain"},
|
||
}, nil
|
||
}
|
||
|
||
// JWKS 返回 JSON Web Key Set
|
||
func (s *OIDCService) JWKS() (map[string]any, error) {
|
||
s.mu.RLock()
|
||
key := s.privateKey
|
||
s.mu.RUnlock()
|
||
if key == nil {
|
||
return nil, ErrOIDCNotConfigured
|
||
}
|
||
pub := key.PublicKey
|
||
return map[string]any{
|
||
"keys": []map[string]string{
|
||
{
|
||
"kty": "RSA",
|
||
"use": "sig",
|
||
"alg": "RS256",
|
||
"kid": oidcKeyID,
|
||
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
|
||
"e": base64.RawURLEncoding.EncodeToString(bigIntBytes(pub.E)),
|
||
},
|
||
},
|
||
}, nil
|
||
}
|
||
|
||
func bigIntBytes(e int) []byte {
|
||
if e == 0 {
|
||
return []byte{0}
|
||
}
|
||
var b []byte
|
||
for v := e; v > 0; v >>= 8 {
|
||
b = append([]byte{byte(v & 0xff)}, b...)
|
||
}
|
||
return b
|
||
}
|
||
|
||
// AuthorizeRequest 授权端点查询参数
|
||
type AuthorizeRequest struct {
|
||
ClientID string
|
||
RedirectURI string
|
||
ResponseType string
|
||
Scope string
|
||
State string
|
||
Nonce string
|
||
CodeChallenge string
|
||
CodeChallengeMethod string
|
||
}
|
||
|
||
// ValidateAuthorize 校验授权请求(不要求已登录)
|
||
func (s *OIDCService) ValidateAuthorize(req AuthorizeRequest) error {
|
||
rt := s.runtime()
|
||
if !rt.Ready {
|
||
return ErrOIDCNotConfigured
|
||
}
|
||
client, err := FindEnabledOAuthClient(req.ClientID)
|
||
if err != nil {
|
||
return ErrOIDCInvalidClient
|
||
}
|
||
if req.ResponseType != "code" {
|
||
return ErrOIDCInvalidRequest
|
||
}
|
||
if !redirectAllowed(client.RedirectURIs, req.RedirectURI) {
|
||
return ErrOIDCInvalidRedirect
|
||
}
|
||
if !hasScope(req.Scope, "openid") {
|
||
return ErrOIDCInvalidRequest
|
||
}
|
||
if req.CodeChallenge != "" {
|
||
m := strings.ToUpper(req.CodeChallengeMethod)
|
||
if m == "" {
|
||
m = "PLAIN"
|
||
}
|
||
if m != "S256" && m != "PLAIN" {
|
||
return ErrOIDCInvalidRequest
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func hasScope(scope, want string) bool {
|
||
for _, p := range strings.Fields(scope) {
|
||
if p == want {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func redirectAllowed(redirectURIsCSV, uri string) bool {
|
||
for _, allowed := range splitRedirectURIs(redirectURIsCSV) {
|
||
if allowed == uri {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// IssueAuthCode 已登录用户签发授权码,返回带 code/state 的回调 URL
|
||
func (s *OIDCService) IssueAuthCode(userID uint, req AuthorizeRequest) (string, error) {
|
||
if err := s.ValidateAuthorize(req); err != nil {
|
||
return "", err
|
||
}
|
||
var user model.User
|
||
if err := model.DB.First(&user, userID).Error; err != nil {
|
||
return "", ErrOIDCInvalidRequest
|
||
}
|
||
if user.Banned {
|
||
return "", ErrOIDCUserBanned
|
||
}
|
||
|
||
code, err := randomToken(32)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
method := strings.ToUpper(req.CodeChallengeMethod)
|
||
if req.CodeChallenge != "" && method == "" {
|
||
method = "PLAIN"
|
||
}
|
||
rec := &model.OAuthAuthCode{
|
||
Code: code,
|
||
ClientID: req.ClientID,
|
||
UserID: user.ID,
|
||
RedirectURI: req.RedirectURI,
|
||
Scope: req.Scope,
|
||
Nonce: req.Nonce,
|
||
CodeChallenge: req.CodeChallenge,
|
||
CodeChallengeMethod: method,
|
||
ExpiresAt: time.Now().Add(oidcAuthCodeTTL),
|
||
}
|
||
if err := model.DB.Create(rec).Error; err != nil {
|
||
return "", err
|
||
}
|
||
|
||
u, err := url.Parse(req.RedirectURI)
|
||
if err != nil {
|
||
return "", ErrOIDCInvalidRedirect
|
||
}
|
||
q := u.Query()
|
||
q.Set("code", code)
|
||
if req.State != "" {
|
||
q.Set("state", req.State)
|
||
}
|
||
u.RawQuery = q.Encode()
|
||
return u.String(), nil
|
||
}
|
||
|
||
// TokenRequest 换票请求
|
||
type TokenRequest struct {
|
||
GrantType string
|
||
Code string
|
||
RedirectURI string
|
||
ClientID string
|
||
ClientSecret string
|
||
CodeVerifier string
|
||
}
|
||
|
||
// TokenResponse OAuth token 响应
|
||
type TokenResponse struct {
|
||
AccessToken string `json:"access_token"`
|
||
TokenType string `json:"token_type"`
|
||
ExpiresIn int `json:"expires_in"`
|
||
IDToken string `json:"id_token,omitempty"`
|
||
Scope string `json:"scope,omitempty"`
|
||
RefreshToken string `json:"refresh_token,omitempty"`
|
||
}
|
||
|
||
// ExchangeCode 授权码换 token
|
||
func (s *OIDCService) ExchangeCode(req TokenRequest) (*TokenResponse, error) {
|
||
rt := s.runtime()
|
||
if !rt.Ready {
|
||
return nil, ErrOIDCNotConfigured
|
||
}
|
||
if req.GrantType != "authorization_code" {
|
||
return nil, ErrOIDCInvalidRequest
|
||
}
|
||
client, err := FindEnabledOAuthClient(req.ClientID)
|
||
if err != nil || !VerifyOAuthClientSecret(client, req.ClientSecret) {
|
||
return nil, ErrOIDCInvalidClient
|
||
}
|
||
|
||
var rec model.OAuthAuthCode
|
||
if err := model.DB.Where("code = ?", req.Code).First(&rec).Error; err != nil {
|
||
return nil, ErrOIDCInvalidGrant
|
||
}
|
||
if rec.Used || time.Now().After(rec.ExpiresAt) {
|
||
// 重放:作废同用户同客户端未过期码
|
||
if rec.Used {
|
||
_ = model.DB.Model(&model.OAuthAuthCode{}).
|
||
Where("client_id = ? AND user_id = ? AND used = ? AND expires_at > ?",
|
||
rec.ClientID, rec.UserID, false, time.Now()).
|
||
Update("used", true).Error
|
||
}
|
||
return nil, ErrOIDCInvalidGrant
|
||
}
|
||
if rec.ClientID != req.ClientID || rec.RedirectURI != req.RedirectURI {
|
||
return nil, ErrOIDCInvalidGrant
|
||
}
|
||
if err := verifyPKCE(rec.CodeChallenge, rec.CodeChallengeMethod, req.CodeVerifier); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
rec.Used = true
|
||
_ = model.DB.Save(&rec).Error
|
||
|
||
var user model.User
|
||
if err := model.DB.First(&user, rec.UserID).Error; err != nil || user.Banned {
|
||
return nil, ErrOIDCInvalidGrant
|
||
}
|
||
|
||
access, err := s.signAccessToken(&user, rec.Scope, req.ClientID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
idToken, err := s.signIDToken(&user, rec.Scope, req.ClientID, rec.Nonce)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return &TokenResponse{
|
||
AccessToken: access,
|
||
TokenType: "Bearer",
|
||
ExpiresIn: int(oidcAccessTokenTTL.Seconds()),
|
||
IDToken: idToken,
|
||
Scope: rec.Scope,
|
||
}, nil
|
||
}
|
||
|
||
func verifyPKCE(challenge, method, verifier string) error {
|
||
if challenge == "" {
|
||
return nil
|
||
}
|
||
if verifier == "" {
|
||
return ErrOIDCPKCEFailed
|
||
}
|
||
switch strings.ToUpper(method) {
|
||
case "S256":
|
||
sum := sha256.Sum256([]byte(verifier))
|
||
calc := base64.RawURLEncoding.EncodeToString(sum[:])
|
||
if calc != challenge {
|
||
return ErrOIDCPKCEFailed
|
||
}
|
||
case "PLAIN", "":
|
||
if verifier != challenge {
|
||
return ErrOIDCPKCEFailed
|
||
}
|
||
default:
|
||
return ErrOIDCPKCEFailed
|
||
}
|
||
return nil
|
||
}
|
||
|
||
type oidcAccessClaims struct {
|
||
Scope string `json:"scope,omitempty"`
|
||
ClientID string `json:"client_id,omitempty"`
|
||
jwt.RegisteredClaims
|
||
}
|
||
|
||
type oidcIDClaims struct {
|
||
Name string `json:"name,omitempty"`
|
||
PreferredUsername string `json:"preferred_username,omitempty"`
|
||
Email string `json:"email,omitempty"`
|
||
EmailVerified bool `json:"email_verified,omitempty"`
|
||
Picture string `json:"picture,omitempty"`
|
||
Groups []string `json:"groups,omitempty"`
|
||
Nonce string `json:"nonce,omitempty"`
|
||
jwt.RegisteredClaims
|
||
}
|
||
|
||
func (s *OIDCService) signAccessToken(user *model.User, scope, clientID string) (string, error) {
|
||
now := time.Now()
|
||
issuer := s.Issuer()
|
||
claims := oidcAccessClaims{
|
||
Scope: scope,
|
||
ClientID: clientID,
|
||
RegisteredClaims: jwt.RegisteredClaims{
|
||
Issuer: issuer,
|
||
Subject: strconv.FormatUint(uint64(user.ID), 10),
|
||
Audience: []string{clientID},
|
||
IssuedAt: jwt.NewNumericDate(now),
|
||
ExpiresAt: jwt.NewNumericDate(now.Add(oidcAccessTokenTTL)),
|
||
},
|
||
}
|
||
t := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||
t.Header["kid"] = oidcKeyID
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
return t.SignedString(s.privateKey)
|
||
}
|
||
|
||
func (s *OIDCService) signIDToken(user *model.User, scope, clientID, nonce string) (string, error) {
|
||
now := time.Now()
|
||
issuer := s.Issuer()
|
||
claims := oidcIDClaims{
|
||
Nonce: nonce,
|
||
Groups: s.userGroups(user),
|
||
RegisteredClaims: jwt.RegisteredClaims{
|
||
Issuer: issuer,
|
||
Subject: strconv.FormatUint(uint64(user.ID), 10),
|
||
Audience: []string{clientID},
|
||
IssuedAt: jwt.NewNumericDate(now),
|
||
ExpiresAt: jwt.NewNumericDate(now.Add(oidcIDTokenTTL)),
|
||
},
|
||
}
|
||
if hasScope(scope, "profile") || scope == "" || hasScope(scope, "openid") {
|
||
claims.Name = user.Nickname
|
||
if claims.Name == "" {
|
||
claims.Name = user.Username
|
||
}
|
||
claims.PreferredUsername = user.Username
|
||
claims.Picture = s.absoluteURL(user.Avatar)
|
||
}
|
||
if hasScope(scope, "email") || hasScope(scope, "openid") {
|
||
claims.Email = user.Email
|
||
claims.EmailVerified = user.Email != ""
|
||
}
|
||
t := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||
t.Header["kid"] = oidcKeyID
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
return t.SignedString(s.privateKey)
|
||
}
|
||
|
||
func (s *OIDCService) userGroups(user *model.User) []string {
|
||
rt := s.runtime()
|
||
groups := make([]string, 0, 2)
|
||
if rt.UserGroup != "" {
|
||
groups = append(groups, rt.UserGroup)
|
||
}
|
||
if user.Role == model.RoleAdmin && rt.AdminGroup != "" {
|
||
groups = append(groups, rt.AdminGroup)
|
||
}
|
||
return groups
|
||
}
|
||
|
||
// UserInfo 根据 access_token 返回用户声明
|
||
func (s *OIDCService) UserInfo(accessToken string) (map[string]any, error) {
|
||
claims, err := s.parseAccessToken(accessToken)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
uid, err := strconv.ParseUint(claims.Subject, 10, 64)
|
||
if err != nil {
|
||
return nil, ErrOIDCInvalidToken
|
||
}
|
||
var user model.User
|
||
if err := model.DB.First(&user, uint(uid)).Error; err != nil || user.Banned {
|
||
return nil, ErrOIDCInvalidToken
|
||
}
|
||
rt := s.runtime()
|
||
out := map[string]any{
|
||
"sub": strconv.FormatUint(uint64(user.ID), 10),
|
||
}
|
||
if hasScope(claims.Scope, "profile") || claims.Scope == "" {
|
||
name := user.Nickname
|
||
if name == "" {
|
||
name = user.Username
|
||
}
|
||
out["name"] = name
|
||
out["preferred_username"] = user.Username
|
||
if pic := s.absoluteURL(user.Avatar); pic != "" {
|
||
out["picture"] = pic
|
||
}
|
||
}
|
||
if hasScope(claims.Scope, "email") || hasScope(claims.Scope, "openid") {
|
||
if user.Email != "" {
|
||
out["email"] = user.Email
|
||
out["email_verified"] = true
|
||
}
|
||
}
|
||
if _, ok := out["preferred_username"]; !ok {
|
||
out["preferred_username"] = user.Username
|
||
out["name"] = user.Nickname
|
||
if out["name"] == "" {
|
||
out["name"] = user.Username
|
||
}
|
||
}
|
||
groups := s.userGroups(&user)
|
||
if len(groups) > 0 {
|
||
claim := rt.GroupClaim
|
||
if claim == "" {
|
||
claim = "groups"
|
||
}
|
||
out[claim] = groups
|
||
if claim != "groups" {
|
||
out["groups"] = groups
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// ResolveLogoutRedirect 校验并返回登出后跳转地址(空表示回首页)
|
||
func (s *OIDCService) ResolveLogoutRedirect(postLogoutRedirectURI, state string) (string, error) {
|
||
uri := strings.TrimSpace(postLogoutRedirectURI)
|
||
if uri == "" {
|
||
return "/", nil
|
||
}
|
||
var clients []model.OAuthClient
|
||
if err := model.DB.Where("enabled = ?", true).Find(&clients).Error; err != nil {
|
||
return "", err
|
||
}
|
||
allowed := false
|
||
for _, c := range clients {
|
||
if redirectAllowed(c.RedirectURIs, uri) {
|
||
allowed = true
|
||
break
|
||
}
|
||
// 允许同 host 下任意已登记前缀的登出回调(Gitea 常用 / 根路径)
|
||
for _, reg := range splitRedirectURIs(c.RedirectURIs) {
|
||
if sameOrigin(reg, uri) {
|
||
allowed = true
|
||
break
|
||
}
|
||
}
|
||
if allowed {
|
||
break
|
||
}
|
||
}
|
||
if !allowed {
|
||
return "", ErrOIDCInvalidLogout
|
||
}
|
||
u, err := url.Parse(uri)
|
||
if err != nil {
|
||
return "", ErrOIDCInvalidLogout
|
||
}
|
||
if state != "" {
|
||
q := u.Query()
|
||
q.Set("state", state)
|
||
u.RawQuery = q.Encode()
|
||
}
|
||
return u.String(), nil
|
||
}
|
||
|
||
func sameOrigin(a, b string) bool {
|
||
ua, err1 := url.Parse(a)
|
||
ub, err2 := url.Parse(b)
|
||
if err1 != nil || err2 != nil {
|
||
return false
|
||
}
|
||
return strings.EqualFold(ua.Scheme, ub.Scheme) && strings.EqualFold(ua.Host, ub.Host)
|
||
}
|
||
|
||
func (s *OIDCService) parseAccessToken(tokenStr string) (*oidcAccessClaims, error) {
|
||
s.mu.RLock()
|
||
key := s.privateKey
|
||
s.mu.RUnlock()
|
||
if key == nil {
|
||
return nil, ErrOIDCNotConfigured
|
||
}
|
||
tok, err := jwt.ParseWithClaims(tokenStr, &oidcAccessClaims{}, func(t *jwt.Token) (any, error) {
|
||
if t.Method != jwt.SigningMethodRS256 {
|
||
return nil, ErrOIDCInvalidToken
|
||
}
|
||
return &key.PublicKey, nil
|
||
})
|
||
if err != nil || !tok.Valid {
|
||
return nil, ErrOIDCInvalidToken
|
||
}
|
||
claims, ok := tok.Claims.(*oidcAccessClaims)
|
||
if !ok {
|
||
return nil, ErrOIDCInvalidToken
|
||
}
|
||
if claims.Issuer != s.Issuer() {
|
||
return nil, ErrOIDCInvalidToken
|
||
}
|
||
return claims, nil
|
||
}
|
||
|
||
func (s *OIDCService) absoluteURL(path string) string {
|
||
path = strings.TrimSpace(path)
|
||
if path == "" {
|
||
return ""
|
||
}
|
||
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||
return path
|
||
}
|
||
if !strings.HasPrefix(path, "/") {
|
||
path = "/" + path
|
||
}
|
||
base := s.Issuer()
|
||
if base == "" {
|
||
return path
|
||
}
|
||
return base + path
|
||
}
|
||
|
||
func randomToken(nBytes int) (string, error) {
|
||
b := make([]byte, nBytes)
|
||
if _, err := rand.Read(b); err != nil {
|
||
return "", err
|
||
}
|
||
return hex.EncodeToString(b), nil
|
||
}
|