feat(auth): OIDC/Keycloak SSO-Login (additiv) — v1.2.91
SSO per OpenID Connect (Authorization Code + PKCE) zusätzlich zum lokalen Login.
- Regeln: kein Auto-Provisioning (E-Mail muss als User existieren), Rolle aus DB (nie aus Token), lokaler Login+TOTP unangetastet.
- Migration 0040: oidc_settings (Singleton, client_secret_enc via secrets.Box) + users.oidc_subject.
- internal/services/oidc: Settings-Repo (write-only Secret) + lazy go-oidc Client (testbarer Authenticator-Seam).
- internal/handlers/oidc.go: GET/PUT /oidc/settings (admin), GET /auth/oidc/{settings,login,callback}. Flow-State (state/PKCE/nonce) stateless im 5-min signierten HttpOnly-Cookie (SameSite=Lax). email_verified erzwungen, opportunistisches sub-Linking, Session via setSessionCookie+Signer.
- session.SignBlob/VerifyBlob; users.Get/SetOIDCSubject; main.go-Wiring.
- Frontend: App.tsx /auth/me-Bootstrap (für Cookie-Session nach Callback), Login-SSO-Button + sso_error, Settings OIDC-Card, i18n de/en.
- Tests (guarded EG_FWTEST_DSN): Secret-Roundtrip + Callback (Rolle-aus-DB, no_account, disabled, unverified, nonce, state).
Deps: go-oidc/v3, x/oauth2. Scope v1: nur Login (kein SLO/Refresh).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
181
internal/services/oidc/client.go
Normal file
181
internal/services/oidc/client.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
gooidc "github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// Claims sind die aus dem ID-Token extrahierten Felder, die der Login-
|
||||
// Flow braucht. Bewusst minimal — Rolle kommt NIE aus dem Token.
|
||||
type Claims struct {
|
||||
Subject string
|
||||
Email string
|
||||
EmailVerified bool
|
||||
Nonce string
|
||||
}
|
||||
|
||||
// Authenticator ist der testbare Seam: Aufbau der Auth-URL und der
|
||||
// Code-Exchange inkl. ID-Token-Verifikation + Claim-Extraktion. Der
|
||||
// Handler hängt nur hieran, sodass Tests einen Fake injizieren können.
|
||||
type Authenticator interface {
|
||||
// AuthCodeURL baut die Redirect-URL zum IdP (state + nonce + PKCE-Challenge).
|
||||
AuthCodeURL(ctx context.Context, redirectURI, state, nonce, pkceVerifier string) (string, error)
|
||||
// Exchange tauscht den Code (PKCE), verifiziert das ID-Token und gibt
|
||||
// die Claims zurück. Prüft Issuer/Audience/Signatur/Expiry.
|
||||
Exchange(ctx context.Context, redirectURI, code, pkceVerifier string) (*Claims, error)
|
||||
}
|
||||
|
||||
// Client implementiert Authenticator gegen einen echten OIDC-Provider.
|
||||
// Provider+Verifier werden lazy aufgebaut und gecached; bei geänderten
|
||||
// Settings (Fingerprint) neu aufgebaut.
|
||||
type Client struct {
|
||||
repo *Repo
|
||||
|
||||
mu sync.Mutex
|
||||
cacheKey string
|
||||
provider *gooidc.Provider
|
||||
verifier *gooidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
func NewClient(repo *Repo) *Client { return &Client{repo: repo} }
|
||||
|
||||
// loaded baut (oder reused) Provider+Verifier aus den aktuellen Settings.
|
||||
// Cache-Key = Fingerprint(issuer, client_id, scopes); Rebuild bei Änderung.
|
||||
func (c *Client) loaded(ctx context.Context) (*gooidc.Provider, *gooidc.IDTokenVerifier, error) {
|
||||
s, err := c.repo.Get(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !s.Enabled {
|
||||
return nil, nil, ErrDisabled
|
||||
}
|
||||
if strings.TrimSpace(s.IssuerURL) == "" || strings.TrimSpace(s.ClientID) == "" {
|
||||
return nil, nil, fmt.Errorf("oidc: issuer_url and client_id required")
|
||||
}
|
||||
key := fingerprint(s.IssuerURL, s.ClientID, s.Scopes)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.provider == nil || c.cacheKey != key {
|
||||
prov, err := gooidc.NewProvider(ctx, s.IssuerURL)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("oidc: discovery: %w", err)
|
||||
}
|
||||
c.provider = prov
|
||||
c.verifier = prov.Verifier(&gooidc.Config{ClientID: s.ClientID})
|
||||
c.cacheKey = key
|
||||
}
|
||||
return c.provider, c.verifier, nil
|
||||
}
|
||||
|
||||
func (c *Client) oauthConfig(prov *gooidc.Provider, clientID, secret, redirectURI, scopes string) oauth2.Config {
|
||||
return oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: secret,
|
||||
Endpoint: prov.Endpoint(),
|
||||
RedirectURL: redirectURI,
|
||||
Scopes: splitScopes(scopes),
|
||||
}
|
||||
}
|
||||
|
||||
// AuthCodeURL implementiert Authenticator.
|
||||
func (c *Client) AuthCodeURL(ctx context.Context, redirectURI, state, nonce, pkceVerifier string) (string, error) {
|
||||
s, err := c.repo.Get(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
prov, _, err := c.loaded(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secret, _ := c.repo.ClientSecret(ctx)
|
||||
cfg := c.oauthConfig(prov, s.ClientID, secret, redirectURI, s.Scopes)
|
||||
return cfg.AuthCodeURL(state,
|
||||
gooidc.Nonce(nonce),
|
||||
oauth2.S256ChallengeOption(pkceVerifier),
|
||||
), nil
|
||||
}
|
||||
|
||||
// Exchange implementiert Authenticator.
|
||||
func (c *Client) Exchange(ctx context.Context, redirectURI, code, pkceVerifier string) (*Claims, error) {
|
||||
s, err := c.repo.Get(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prov, verifier, err := c.loaded(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secret, _ := c.repo.ClientSecret(ctx)
|
||||
cfg := c.oauthConfig(prov, s.ClientID, secret, redirectURI, s.Scopes)
|
||||
|
||||
tok, err := cfg.Exchange(ctx, code, oauth2.VerifierOption(pkceVerifier))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: code exchange: %w", err)
|
||||
}
|
||||
rawID, ok := tok.Extra("id_token").(string)
|
||||
if !ok || rawID == "" {
|
||||
return nil, errors.New("oidc: no id_token in response")
|
||||
}
|
||||
idToken, err := verifier.Verify(ctx, rawID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc: id_token verify: %w", err)
|
||||
}
|
||||
return extractClaims(idToken, s.EmailClaim)
|
||||
}
|
||||
|
||||
// extractClaims liest E-Mail (via konfigurierbarem Claim), email_verified,
|
||||
// sub und nonce aus dem verifizierten ID-Token.
|
||||
func extractClaims(idToken *gooidc.IDToken, emailClaim string) (*Claims, error) {
|
||||
var raw map[string]any
|
||||
if err := idToken.Claims(&raw); err != nil {
|
||||
return nil, fmt.Errorf("oidc: decode claims: %w", err)
|
||||
}
|
||||
if emailClaim == "" {
|
||||
emailClaim = "email"
|
||||
}
|
||||
out := &Claims{Subject: idToken.Subject}
|
||||
if v, ok := raw[emailClaim].(string); ok {
|
||||
out.Email = strings.TrimSpace(strings.ToLower(v))
|
||||
}
|
||||
// email_verified kann bool oder "true"/"false" sein.
|
||||
switch ev := raw["email_verified"].(type) {
|
||||
case bool:
|
||||
out.EmailVerified = ev
|
||||
case string:
|
||||
out.EmailVerified = ev == "true"
|
||||
}
|
||||
if n, ok := raw["nonce"].(string); ok {
|
||||
out.Nonce = n
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func splitScopes(s string) []string {
|
||||
out := []string{}
|
||||
for _, p := range strings.Fields(s) {
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
out = []string{gooidc.ScopeOpenID, "email", "profile"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fingerprint(parts ...string) string {
|
||||
h := sha256.New()
|
||||
for _, p := range parts {
|
||||
h.Write([]byte(p))
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
111
internal/services/oidc/settings.go
Normal file
111
internal/services/oidc/settings.go
Normal file
@@ -0,0 +1,111 @@
|
||||
// Package oidc kapselt die OIDC/Keycloak-SSO-Konfiguration (Singleton-
|
||||
// Settings + verschlüsseltes Client-Secret) und einen lazy aufgebauten
|
||||
// OIDC-Provider/Verifier. Login-Flow-State ist stateless (signiertes
|
||||
// Cookie im Handler), daher hält dieses Paket keinen Request-State.
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
|
||||
)
|
||||
|
||||
// ErrDisabled signalisiert, dass OIDC nicht aktiviert/konfiguriert ist.
|
||||
var ErrDisabled = errors.New("oidc: not enabled")
|
||||
|
||||
// Repo liest/schreibt die oidc_settings-Singleton-Row und ver-/entschlüsselt
|
||||
// das Client-Secret via secrets.Box.
|
||||
type Repo struct {
|
||||
pool *pgxpool.Pool
|
||||
box *secrets.Box
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool, box *secrets.Box) *Repo {
|
||||
return &Repo{pool: pool, box: box}
|
||||
}
|
||||
|
||||
// Get liefert die Settings (client_secret_enc als Bytes, NULL → nil).
|
||||
func (r *Repo) Get(ctx context.Context) (*models.OIDCSettings, error) {
|
||||
var s models.OIDCSettings
|
||||
if err := r.pool.QueryRow(ctx, `
|
||||
SELECT id, enabled, issuer_url, client_id, client_secret_enc,
|
||||
scopes, email_claim, button_label, created_at, updated_at
|
||||
FROM oidc_settings WHERE id = 1`).Scan(
|
||||
&s.ID, &s.Enabled, &s.IssuerURL, &s.ClientID, &s.ClientSecretEnc,
|
||||
&s.Scopes, &s.EmailClaim, &s.ButtonLabel, &s.CreatedAt, &s.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// ClientSecret entschlüsselt das gespeicherte Client-Secret ("" wenn keins).
|
||||
func (r *Repo) ClientSecret(ctx context.Context) (string, error) {
|
||||
s, err := r.Get(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(s.ClientSecretEnc) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
pt, err := r.box.Open(s.ClientSecretEnc)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(pt), nil
|
||||
}
|
||||
|
||||
// HasSecret meldet, ob ein Client-Secret hinterlegt ist (für die Admin-UI,
|
||||
// ohne das Secret selbst preiszugeben).
|
||||
func (r *Repo) HasSecret(ctx context.Context) (bool, error) {
|
||||
var present bool
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT client_secret_enc IS NOT NULL FROM oidc_settings WHERE id = 1`).Scan(&present)
|
||||
return present, err
|
||||
}
|
||||
|
||||
// UpdateInput beschreibt eine Settings-Änderung. ClientSecret nutzt
|
||||
// write-only-Semantik: nil = unverändert, "" = löschen, sonst neu sealen.
|
||||
type UpdateInput struct {
|
||||
Enabled bool
|
||||
IssuerURL string
|
||||
ClientID string
|
||||
Scopes string
|
||||
EmailClaim string
|
||||
ButtonLabel string
|
||||
ClientSecret *string
|
||||
}
|
||||
|
||||
// Update schreibt die Settings. Das Secret wird nur angefasst, wenn
|
||||
// ClientSecret != nil.
|
||||
func (r *Repo) Update(ctx context.Context, in UpdateInput) error {
|
||||
if in.ClientSecret == nil {
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE oidc_settings
|
||||
SET enabled=$1, issuer_url=$2, client_id=$3, scopes=$4,
|
||||
email_claim=$5, button_label=$6, updated_at=NOW()
|
||||
WHERE id=1`,
|
||||
in.Enabled, in.IssuerURL, in.ClientID, in.Scopes, in.EmailClaim, in.ButtonLabel)
|
||||
return err
|
||||
}
|
||||
|
||||
var enc []byte
|
||||
if *in.ClientSecret != "" {
|
||||
sealed, err := r.box.Seal([]byte(*in.ClientSecret))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enc = sealed
|
||||
}
|
||||
_, err := r.pool.Exec(ctx, `
|
||||
UPDATE oidc_settings
|
||||
SET enabled=$1, issuer_url=$2, client_id=$3, scopes=$4,
|
||||
email_claim=$5, button_label=$6, client_secret_enc=$7, updated_at=NOW()
|
||||
WHERE id=1`,
|
||||
in.Enabled, in.IssuerURL, in.ClientID, in.Scopes, in.EmailClaim, in.ButtonLabel, enc)
|
||||
return err
|
||||
}
|
||||
90
internal/services/oidc/settings_test.go
Normal file
90
internal/services/oidc/settings_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
|
||||
)
|
||||
|
||||
// migrateRetry umgeht die goose-Erst-Apply-Race, wenn mehrere guarded
|
||||
// Test-Pakete dieselbe frische DB parallel migrieren.
|
||||
func migrateRetry(ctx context.Context, dsn string) error {
|
||||
var err error
|
||||
for i := 0; i < 3; i++ {
|
||||
if err = database.Migrate(ctx, dsn); err == nil {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Guarded integration test: set EG_FWTEST_DSN (sonst skip).
|
||||
func testRepo(t *testing.T) *Repo {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("EG_FWTEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set EG_FWTEST_DSN to run the oidc settings test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := migrateRetry(ctx, dsn); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
pool, err := database.Open(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
box := secrets.New(t.TempDir() + "/master_key")
|
||||
// Settings auf einen sauberen Default zurücksetzen.
|
||||
if _, err := pool.Exec(ctx, `UPDATE oidc_settings SET enabled=false, issuer_url='', client_id='', client_secret_enc=NULL WHERE id=1`); err != nil {
|
||||
t.Fatalf("reset: %v", err)
|
||||
}
|
||||
return New(pool, box)
|
||||
}
|
||||
|
||||
func TestSettings_SecretWriteOnly(t *testing.T) {
|
||||
r := testRepo(t)
|
||||
ctx := context.Background()
|
||||
str := func(s string) *string { return &s }
|
||||
|
||||
// 1) Neues Secret setzen.
|
||||
if err := r.Update(ctx, UpdateInput{Enabled: true, IssuerURL: "https://idp.example/realms/x", ClientID: "eg", ClientSecret: str("s3cr3t")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if has, _ := r.HasSecret(ctx); !has {
|
||||
t.Fatal("HasSecret should be true after setting a secret")
|
||||
}
|
||||
got, err := r.ClientSecret(ctx)
|
||||
if err != nil || got != "s3cr3t" {
|
||||
t.Fatalf("ClientSecret = %q, %v; want s3cr3t", got, err)
|
||||
}
|
||||
|
||||
// 2) Update mit nil → Secret bleibt unverändert.
|
||||
if err := r.Update(ctx, UpdateInput{Enabled: true, IssuerURL: "https://idp.example/realms/x", ClientID: "eg2", ClientSecret: nil}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ = r.ClientSecret(ctx)
|
||||
if got != "s3cr3t" {
|
||||
t.Fatalf("secret should be preserved on nil update, got %q", got)
|
||||
}
|
||||
if s, _ := r.Get(ctx); s.ClientID != "eg2" {
|
||||
t.Fatalf("client_id should update to eg2, got %q", s.ClientID)
|
||||
}
|
||||
|
||||
// 3) Update mit "" → Secret gelöscht.
|
||||
if err := r.Update(ctx, UpdateInput{Enabled: false, IssuerURL: "", ClientID: "", ClientSecret: str("")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if has, _ := r.HasSecret(ctx); has {
|
||||
t.Fatal("HasSecret should be false after clearing the secret")
|
||||
}
|
||||
got, _ = r.ClientSecret(ctx)
|
||||
if got != "" {
|
||||
t.Fatalf("secret should be empty after clear, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -174,3 +174,67 @@ var (
|
||||
ErrInvalidToken = errors.New("invalid session token")
|
||||
ErrExpiredToken = errors.New("session token expired")
|
||||
)
|
||||
|
||||
// blobEnvelope umhüllt eine beliebige Payload mit einem Ablaufzeitpunkt.
|
||||
type blobEnvelope struct {
|
||||
Exp int64 `json:"exp"`
|
||||
Payload []byte `json:"p"`
|
||||
}
|
||||
|
||||
// SignBlob signiert beliebige Bytes mit dem Session-Secret (HMAC-SHA256,
|
||||
// gleiches Format wie Tokens: base64url(json).base64url(sig)) und einer
|
||||
// TTL. Für stateless, cluster-sichere Kurzzeit-Cookies (z.B. der
|
||||
// OIDC-Flow-State). Das Secret ist clusterweit synchron (.jwt_fingerprint).
|
||||
func (s *Signer) SignBlob(payload []byte, ttl time.Duration) (string, error) {
|
||||
env := blobEnvelope{
|
||||
Exp: s.Now().Add(ttl).Unix(),
|
||||
Payload: payload,
|
||||
}
|
||||
data, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
mac := hmac.New(sha256.New, s.Secret)
|
||||
mac.Write(data)
|
||||
return base64.RawURLEncoding.EncodeToString(data) + "." +
|
||||
base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// VerifyBlob prüft Signatur + Ablauf und gibt die ursprüngliche Payload
|
||||
// zurück. ErrInvalidToken / ErrExpiredToken bei Fehlern.
|
||||
func (s *Signer) VerifyBlob(raw string) ([]byte, error) {
|
||||
if raw == "" {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
dot := -1
|
||||
for i := 0; i < len(raw); i++ {
|
||||
if raw[i] == '.' {
|
||||
dot = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if dot <= 0 || dot >= len(raw)-1 {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(raw[:dot])
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
sig, err := base64.RawURLEncoding.DecodeString(raw[dot+1:])
|
||||
if err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
mac := hmac.New(sha256.New, s.Secret)
|
||||
mac.Write(payload)
|
||||
if subtle.ConstantTimeCompare(mac.Sum(nil), sig) != 1 {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
var env blobEnvelope
|
||||
if err := json.Unmarshal(payload, &env); err != nil {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
if s.Now().Unix() >= env.Exp {
|
||||
return nil, ErrExpiredToken
|
||||
}
|
||||
return env.Payload, nil
|
||||
}
|
||||
|
||||
@@ -240,6 +240,21 @@ func (r *Repo) RecordLogin(ctx context.Context, id int64) {
|
||||
_, _ = r.pool.Exec(ctx, `UPDATE users SET last_login_at=NOW() WHERE id=$1`, id)
|
||||
}
|
||||
|
||||
// GetOIDCSubject liefert den gespeicherten OIDC-'sub' des Users ("" wenn
|
||||
// noch nicht verknüpft).
|
||||
func (r *Repo) GetOIDCSubject(ctx context.Context, id int64) (string, error) {
|
||||
var sub string
|
||||
err := r.pool.QueryRow(ctx, `SELECT COALESCE(oidc_subject, '') FROM users WHERE id=$1`, id).Scan(&sub)
|
||||
return sub, err
|
||||
}
|
||||
|
||||
// SetOIDCSubject speichert den OIDC-'sub' beim ersten erfolgreichen
|
||||
// SSO-Login (opportunistisches Linking).
|
||||
func (r *Repo) SetOIDCSubject(ctx context.Context, id int64, sub string) error {
|
||||
_, err := r.pool.Exec(ctx, `UPDATE users SET oidc_subject=$1, updated_at=NOW() WHERE id=$2`, sub, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// VerifyPassword is a constant-time bcrypt compare.
|
||||
func VerifyPassword(hash, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
|
||||
Reference in New Issue
Block a user