Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes. Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending. Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete. Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
538 lines
17 KiB
Go
538 lines
17 KiB
Go
package server
|
||
|
||
// 账号与会话 API(阶段 2:管理员发放制,2026-09-30 用户裁定):
|
||
// 无自助注册;登录(用户名+密码)建立服务端会话(HttpOnly cookie);
|
||
// 初始/重置密码后首登强制改密;管理员建号与重置密码。
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"agent-system-backend/internal/store"
|
||
)
|
||
|
||
var errBadPassword = errors.New("password must be 8-256 characters")
|
||
|
||
func decodeJSON(r *http.Request, v any) error {
|
||
return json.NewDecoder(r.Body).Decode(v)
|
||
}
|
||
|
||
func decodeStrictJSON(r *http.Request, v any) error {
|
||
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
||
decoder.DisallowUnknownFields()
|
||
if err := decoder.Decode(v); err != nil {
|
||
return err
|
||
}
|
||
var extra any
|
||
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||
return errors.New("exactly one JSON object required")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 登录失败退避(进程内;管理员发放制下用户量小,进程内即可,实例重启清零)。
|
||
var loginGuard = struct {
|
||
sync.Mutex
|
||
fails map[string][]time.Time
|
||
}{fails: map[string][]time.Time{}}
|
||
|
||
const (
|
||
loginMaxFails = 5
|
||
loginLockWindow = 30 * time.Second
|
||
)
|
||
|
||
func loginLocked(owner string) bool {
|
||
loginGuard.Lock()
|
||
defer loginGuard.Unlock()
|
||
cut := time.Now().Add(-loginLockWindow)
|
||
kept := loginGuard.fails[owner][:0]
|
||
for _, t := range loginGuard.fails[owner] {
|
||
if t.After(cut) {
|
||
kept = append(kept, t)
|
||
}
|
||
}
|
||
loginGuard.fails[owner] = kept
|
||
return len(kept) >= loginMaxFails
|
||
}
|
||
|
||
func loginFail(owner string) {
|
||
loginGuard.Lock()
|
||
defer loginGuard.Unlock()
|
||
loginGuard.fails[owner] = append(loginGuard.fails[owner], time.Now())
|
||
}
|
||
|
||
func loginClear(owner string) {
|
||
loginGuard.Lock()
|
||
defer loginGuard.Unlock()
|
||
delete(loginGuard.fails, owner)
|
||
}
|
||
|
||
// 兑换入口退避:共享库窗口计数(v29 redeem_attempts),多实例共担同一限流;
|
||
// Key 无属主可先行归因,只能按来源 IP 限流。
|
||
const (
|
||
redeemMaxFails = 10
|
||
redeemWindow = loginLockWindow
|
||
)
|
||
|
||
// requestSecure 判断请求是否经 HTTPS(直连 TLS 或反代 X-Forwarded-Proto 标头)。
|
||
func requestSecure(r *http.Request) bool {
|
||
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||
}
|
||
|
||
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, maxAge int) {
|
||
http.SetCookie(w, &http.Cookie{
|
||
Name: SessionCookie, Value: token, Path: "/", HttpOnly: true,
|
||
SameSite: http.SameSiteLaxMode, MaxAge: maxAge, Secure: requestSecure(r),
|
||
})
|
||
}
|
||
|
||
func authStateJSON(st store.UserAuthState) map[string]any {
|
||
return map[string]any{
|
||
"owner": st.Owner, "name": st.Name, "isAdmin": st.IsAdmin,
|
||
"mustChangePassword": st.MustChangePassword, "passwordSet": st.HasPassword,
|
||
"auth": true,
|
||
}
|
||
}
|
||
|
||
// redeemLoginKey:一次性登录 Key → set_password_only 会话(专用 body,不经 Bearer)。
|
||
// 请求只有 Key:身份由服务端绑定记录确定;按来源 IP 退避防爆破。
|
||
func (s *Server) redeemLoginKey(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
if s.Auth == nil || !s.Auth.Enabled {
|
||
http.Error(w, "auth disabled (embedded single-tenant mode)", http.StatusPreconditionFailed)
|
||
return
|
||
}
|
||
ip, _, _ := strings.Cut(r.RemoteAddr, ":")
|
||
if xf := r.Header.Get("X-Forwarded-For"); xf != "" {
|
||
ip = strings.TrimSpace(strings.Split(xf, ",")[0])
|
||
}
|
||
if blocked, err := s.st.RedeemBlocked(ip, redeemMaxFails, redeemWindow); err == nil && blocked {
|
||
http.Error(w, "too many failed attempts; retry later", http.StatusTooManyRequests)
|
||
return
|
||
}
|
||
var req struct {
|
||
Key string `json:"key"`
|
||
}
|
||
if err := decodeJSON(r, &req); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
token, owner, err := s.st.RedeemLoginKey(strings.TrimSpace(req.Key))
|
||
if err != nil {
|
||
_ = s.st.RecordRedeemFailure(ip) // 尽力记录;限流判定的权威在 RedeemBlocked
|
||
if errors.Is(err, store.ErrConflict) {
|
||
http.Error(w, "login key already used", http.StatusConflict)
|
||
return
|
||
}
|
||
http.Error(w, "invalid or expired login key", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
setSessionCookie(w, r, token, int(10*60))
|
||
st, _ := s.st.GetUserAuthState(owner)
|
||
out := authStateJSON(st)
|
||
out["sessionScope"] = store.SessionScopeSetPasswordOnly
|
||
out["passwordChangeMode"] = store.PasswordChangeModeGrantSetup
|
||
out["reloginRequired"] = false
|
||
writeJSON(w, http.StatusOK, out)
|
||
}
|
||
|
||
// login:用户名+密码 → 会话。失败统一 401(不区分账号是否存在);
|
||
// 连续失败触发进程内退避(429)。
|
||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||
if s.Auth == nil || !s.Auth.Enabled {
|
||
http.Error(w, "auth disabled (embedded single-tenant mode)", http.StatusPreconditionFailed)
|
||
return
|
||
}
|
||
var req struct {
|
||
Owner string `json:"owner"`
|
||
Password string `json:"password"`
|
||
}
|
||
if err := decodeJSON(r, &req); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
owner := strings.TrimSpace(req.Owner)
|
||
if owner == "" || len(req.Password) > 256 {
|
||
http.Error(w, "owner and password required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if loginLocked(owner) {
|
||
http.Error(w, "too many failed attempts; retry later", http.StatusTooManyRequests)
|
||
return
|
||
}
|
||
st, ver, ok := s.st.VerifyLoginWithVersion(owner, req.Password)
|
||
if !ok {
|
||
loginFail(owner)
|
||
http.Error(w, "invalid owner or password", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
loginClear(owner)
|
||
// 待改密/未设密:建受限会话(只能设密);否则正常会话。会话创建绑定
|
||
// 认证版本——密码验证在途期间账户被恢复/重置时本请求不得再建会话。
|
||
scope := store.SessionScopeFull
|
||
maxAge := 7 * 24 * 3600
|
||
if st.MustChangePassword || !st.HasPassword {
|
||
scope = store.SessionScopeSetPasswordOnly
|
||
maxAge = 10 * 60
|
||
}
|
||
token, err := s.st.CreateSessionVerified(owner, scope, time.Duration(maxAge)*time.Second, ver)
|
||
if err != nil {
|
||
if errors.Is(err, store.ErrConflict) {
|
||
http.Error(w, "credentials changed concurrently; retry login", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
setSessionCookie(w, r, token, maxAge)
|
||
out := authStateJSON(st)
|
||
out["sessionScope"] = scope
|
||
out["passwordChangeMode"] = store.PasswordChangeModeCurrentRequired
|
||
writeJSON(w, http.StatusOK, out)
|
||
}
|
||
|
||
// logout:注销当前会话。
|
||
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||
if tok := sessionToken(r); tok != "" {
|
||
s.st.DeleteSession(tok)
|
||
}
|
||
setSessionCookie(w, r, "", -1)
|
||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||
}
|
||
|
||
// changePassword:会话登录者改密。
|
||
// - 受限会话或 must_change/未设密:不要求 current;事务内复验会话与认证版本,
|
||
// 完成后销毁全部会话,要求重新密码登录(reloginRequired=true)。
|
||
// - 普通主动改密:要求 current;单事务完成校验、更新、吊销其他会话(保留当前)。
|
||
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
||
tok := sessionToken(r)
|
||
if tok == "" {
|
||
http.Error(w, "session login required (API keys do not own passwords)", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
owner, scope, ok := s.st.SessionInfo(tok)
|
||
if !ok {
|
||
http.Error(w, "session expired", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
mode, err := s.st.SessionPasswordChangeMode(tok)
|
||
if err != nil {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
var req struct {
|
||
Current string `json:"current"`
|
||
Next string `json:"next"`
|
||
}
|
||
if err := decodeJSON(r, &req); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
if err := validPassword(req.Next); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
hash, err := store.HashPassword(req.Next)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if scope == store.SessionScopeSetPasswordOnly {
|
||
var completeErr error
|
||
switch mode {
|
||
case store.PasswordChangeModeGrantSetup:
|
||
_, completeErr = s.st.CompleteSetPasswordChecked(tok, hash)
|
||
case store.PasswordChangeModeCurrentRequired:
|
||
_, completeErr = s.st.CompleteForcedPasswordChecked(tok, req.Current, hash)
|
||
default:
|
||
http.Error(w, "unsupported password change mode", http.StatusForbidden)
|
||
return
|
||
}
|
||
if completeErr != nil {
|
||
if errors.Is(completeErr, store.ErrBadCredentials) {
|
||
http.Error(w, "current password incorrect", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
if errors.Is(completeErr, store.ErrNotFound) {
|
||
http.Error(w, "session expired", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
writeStoreErr(w, completeErr)
|
||
return
|
||
}
|
||
setSessionCookie(w, r, "", -1)
|
||
st, _ := s.st.GetUserAuthState(owner)
|
||
out := authStateJSON(st)
|
||
out["reloginRequired"] = true
|
||
writeJSON(w, http.StatusOK, out)
|
||
return
|
||
}
|
||
if err := s.st.ChangeUserPasswordActive(owner, req.Current, hash, tok); err != nil {
|
||
if errors.Is(err, store.ErrBadCredentials) {
|
||
http.Error(w, "current password incorrect", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
st, _ := s.st.GetUserAuthState(owner)
|
||
out := authStateJSON(st)
|
||
out["reloginRequired"] = false
|
||
writeJSON(w, http.StatusOK, out)
|
||
}
|
||
|
||
func validPassword(pw string) error {
|
||
if len(pw) < 8 {
|
||
return errBadPassword
|
||
}
|
||
if len(pw) > 256 {
|
||
return errBadPassword
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ---- 管理员:用户发放与重置(requireAdmin)----
|
||
|
||
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||
owner := s.owner(r)
|
||
st, err := s.st.GetUserAuthState(owner)
|
||
if err != nil || !st.Enabled || !st.IsAdmin || s.Auth == nil || !s.Auth.Enabled {
|
||
http.Error(w, "admin required", http.StatusForbidden)
|
||
return "", false
|
||
}
|
||
return owner, true
|
||
}
|
||
|
||
func (s *Server) listAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||
if _, ok := s.requireAdmin(w, r); !ok {
|
||
return
|
||
}
|
||
users, err := s.st.ListUsersAdmin()
|
||
writeJSONOr(w, users, err)
|
||
}
|
||
|
||
func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
|
||
actor, ok := s.requireAdmin(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
var req struct {
|
||
Owner string `json:"owner"`
|
||
Profile store.AccountProfile `json:"profile"`
|
||
}
|
||
if err := decodeStrictJSON(r, &req); err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
req.Owner = strings.TrimSpace(req.Owner)
|
||
if req.Owner == "" || strings.ContainsAny(req.Owner, " \t/") {
|
||
http.Error(w, "owner required (no spaces or slashes)", http.StatusBadRequest)
|
||
return
|
||
}
|
||
account, err := s.st.CreatePendingAccount(actor, req.Owner, req.Profile)
|
||
if err != nil {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, accountProfileJSON(account))
|
||
}
|
||
|
||
func accountProfileJSON(account store.Account) map[string]any {
|
||
return map[string]any{
|
||
"userId": account.ID, "owner": account.Owner, "profile": account.Profile,
|
||
"profileVersion": account.ProfileVersion, "enabled": account.Enabled,
|
||
"stateVersion": account.StateVersion, "isAdmin": account.IsAdmin,
|
||
"passwordSet": account.HasPassword, "initializationState": account.InitializationState,
|
||
"initializationGeneration": account.InitializationGeneration,
|
||
}
|
||
}
|
||
|
||
func (s *Server) requireFullPasswordSession(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||
if s.Auth == nil || !s.Auth.Enabled || AuthVia(r) != AuthViaSession || SessionScope(r) != store.SessionScopeFull {
|
||
http.Error(w, "full password session required", http.StatusForbidden)
|
||
return "", false
|
||
}
|
||
token := sessionToken(r)
|
||
owner, scope, valid := s.st.SessionInfo(token)
|
||
if !valid || scope != store.SessionScopeFull || owner != s.owner(r) {
|
||
http.Error(w, "full password session required", http.StatusForbidden)
|
||
return "", false
|
||
}
|
||
st, err := s.st.GetUserAuthState(owner)
|
||
if err != nil || !st.Enabled || st.MustChangePassword || !st.HasPassword {
|
||
http.Error(w, "full password session required", http.StatusForbidden)
|
||
return "", false
|
||
}
|
||
method, err := s.st.SessionAuthMethod(token)
|
||
if err != nil || method != store.AuthMethodPassword {
|
||
http.Error(w, "password-authenticated session required", http.StatusForbidden)
|
||
return "", false
|
||
}
|
||
return owner, true
|
||
}
|
||
|
||
func (s *Server) meProfile(w http.ResponseWriter, r *http.Request) {
|
||
owner, ok := s.requireFullPasswordSession(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
account, err := s.st.GetAccountByOwner(owner)
|
||
if err != nil {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, accountProfileJSON(account))
|
||
}
|
||
|
||
func (s *Server) patchMeProfile(w http.ResponseWriter, r *http.Request) {
|
||
owner, ok := s.requireFullPasswordSession(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
account, err := s.st.GetAccountByOwner(owner)
|
||
if err != nil {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
s.patchAccountProfile(w, r, owner, account.ID)
|
||
}
|
||
|
||
func (s *Server) adminAccount(w http.ResponseWriter, r *http.Request) {
|
||
if _, ok := s.requireAdmin(w, r); !ok {
|
||
return
|
||
}
|
||
account, err := s.st.GetAccount(r.PathValue("userId"))
|
||
if err != nil {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, accountProfileJSON(account))
|
||
}
|
||
|
||
func (s *Server) patchAdminAccountProfile(w http.ResponseWriter, r *http.Request) {
|
||
actor, ok := s.requireAdmin(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
s.patchAccountProfile(w, r, actor, r.PathValue("userId"))
|
||
}
|
||
|
||
func (s *Server) patchAccountProfile(w http.ResponseWriter, r *http.Request, actor, userID string) {
|
||
var req struct {
|
||
ExpectedProfileVersion int64 `json:"expectedProfileVersion"`
|
||
Patch map[string]string `json:"patch"`
|
||
}
|
||
if err := decodeStrictJSON(r, &req); err != nil || req.Patch == nil {
|
||
http.Error(w, "invalid profile patch", http.StatusBadRequest)
|
||
return
|
||
}
|
||
account, err := s.st.PatchAccountProfile(actor, userID, req.ExpectedProfileVersion, req.Patch)
|
||
if err != nil {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, accountProfileJSON(account))
|
||
}
|
||
|
||
func (s *Server) setAdminAccountState(w http.ResponseWriter, r *http.Request) {
|
||
actor, ok := s.requireAdmin(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
var req struct {
|
||
ExpectedStateVersion int64 `json:"expectedStateVersion"`
|
||
Enabled *bool `json:"enabled"`
|
||
}
|
||
if err := decodeStrictJSON(r, &req); err != nil || req.Enabled == nil {
|
||
http.Error(w, "invalid state change", http.StatusBadRequest)
|
||
return
|
||
}
|
||
account, err := s.st.SetAccountEnabled(actor, r.PathValue("userId"), req.ExpectedStateVersion, *req.Enabled)
|
||
if err != nil {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, accountProfileJSON(account))
|
||
}
|
||
|
||
// resetAdminUserPassword:管理员直接重置密码(兼容保留的既有入口;常规恢复
|
||
// 走签发一次性登录 Key)。单事务:must_change=1、bump auth_version、作废未用
|
||
// 登录 Key、吊销全部会话、审计。
|
||
func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) {
|
||
if _, ok := s.requireAdmin(w, r); ok {
|
||
http.Error(w, "RECOVERY_CONTRACT_REPLACED: propose recovery instead", http.StatusGone)
|
||
}
|
||
}
|
||
|
||
// ---- 管理员:一次性登录 Key(恢复流程主入口)----
|
||
|
||
// issueAdminLoginKey:为指定既有用户签发恢复 Key(明文仅本次响应返回)。
|
||
// 同事务撤销旧恢复 Key 及其受限会话;不改密码、不吊销正常会话、不动业务数据。
|
||
func (s *Server) issueAdminLoginKey(w http.ResponseWriter, r *http.Request) {
|
||
if _, ok := s.requireAdmin(w, r); ok {
|
||
http.Error(w, "RECOVERY_CONTRACT_REPLACED: propose recovery instead", http.StatusGone)
|
||
}
|
||
}
|
||
|
||
// listAdminLoginKeys:指定用户的 Key 状态与有效期(不返回明文)。
|
||
func (s *Server) listAdminLoginKeys(w http.ResponseWriter, r *http.Request) {
|
||
if _, ok := s.requireAdmin(w, r); !ok {
|
||
return
|
||
}
|
||
keys, err := s.st.ListLoginKeys(r.PathValue("owner"))
|
||
writeJSONOr(w, map[string]any{"keys": keys}, err)
|
||
}
|
||
|
||
// revokeAdminLoginKey:撤销 Key 并作废其关联受限会话。
|
||
func (s *Server) revokeAdminLoginKey(w http.ResponseWriter, r *http.Request) {
|
||
actor, ok := s.requireAdmin(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
if err := s.st.RevokeLoginKeyCascadeAdmin(actor, r.PathValue("owner"), r.PathValue("id")); err != nil {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||
}
|
||
|
||
// RandomPassword 生成随机初始密码(管理员/CLI 未显式给定密码时)。
|
||
func RandomPassword() string {
|
||
raw := make([]byte, 9)
|
||
rand.Read(raw)
|
||
return hex.EncodeToString(raw)
|
||
}
|
||
|
||
// ---- 管理员:恢复隔离的核对与解除(协作存储 U3)----
|
||
|
||
// listRecoveryHolds 列出未解除的 recovery hold(人工核对的运维入口)。
|
||
func (s *Server) listRecoveryHolds(w http.ResponseWriter, r *http.Request) {
|
||
if _, ok := s.requireAdmin(w, r); !ok {
|
||
return
|
||
}
|
||
holds, err := s.st.ListOpenRecoveryHolds()
|
||
writeJSONOr(w, map[string]any{"holds": holds}, err)
|
||
}
|
||
|
||
// releaseRecoveryHold 人工解除单条 hold(幂等);解除后该 run 才可能被再次
|
||
// 续跑/接管。副作用未核对就解除的风险由操作者承担——与 task-close 同级谨慎。
|
||
func (s *Server) releaseRecoveryHold(w http.ResponseWriter, r *http.Request) {
|
||
actor, ok := s.requireAdmin(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
runID := r.PathValue("runId")
|
||
if _, err := s.st.GetRecoveryHold(runID); err != nil {
|
||
writeStoreErr(w, err)
|
||
return
|
||
}
|
||
writeOKOr(w, s.st.ReleaseRecoveryHoldAccountChecked(actor, runID, s.Auth == nil || !s.Auth.Enabled))
|
||
}
|