Add SSO login support to MCR web UI
MCR can now redirect users to MCIAS for login instead of showing its own login form. This enables passkey/FIDO2 authentication since WebAuthn credentials are bound to MCIAS's domain. - Add optional [sso] config section with redirect_uri - Add handleSSOLogin (redirects to MCIAS) and handleSSOCallback (exchanges code for JWT, validates roles, sets session cookie) - SSO is opt-in: when redirect_uri is empty, the existing login form is used (backward compatible) - Guest role check preserved in SSO callback path - Return-to URL preserved across the SSO redirect - Uses mcdsl/sso package (local replace for now) Security: - State cookie uses SameSite=Lax for cross-site redirect compatibility - Session cookie remains SameSite=Strict (same-site only after login) - Code exchange is server-to-server over TLS 1.3 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import (
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
|
||||
mcdsso "git.wntrmute.dev/mc/mcdsl/sso"
|
||||
mcrv1 "git.wntrmute.dev/mc/mcr/gen/mcr/v1"
|
||||
"git.wntrmute.dev/mc/mcr/internal/auth"
|
||||
"git.wntrmute.dev/mc/mcr/internal/config"
|
||||
@@ -113,8 +114,23 @@ func runServer(configPath string) error {
|
||||
return fmt.Errorf("generate CSRF key: %w", err)
|
||||
}
|
||||
|
||||
// Create SSO client if the service has an SSO redirect_uri configured.
|
||||
var ssoClient *mcdsso.Client
|
||||
if cfg.SSO.RedirectURI != "" {
|
||||
ssoClient, err = mcdsso.New(mcdsso.Config{
|
||||
MciasURL: cfg.MCIAS.ServerURL,
|
||||
ClientID: cfg.MCIAS.ServiceName,
|
||||
RedirectURI: cfg.SSO.RedirectURI,
|
||||
CACert: cfg.MCIAS.CACert,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create SSO client: %w", err)
|
||||
}
|
||||
log.Printf("SSO enabled: redirecting to %s for login", cfg.MCIAS.ServerURL)
|
||||
}
|
||||
|
||||
// Create web server.
|
||||
srv, err := webserver.New(registryClient, policyClient, auditClient, adminClient, loginFn, validateFn, csrfKey)
|
||||
srv, err := webserver.New(registryClient, policyClient, auditClient, adminClient, loginFn, validateFn, csrfKey, ssoClient)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create web server: %w", err)
|
||||
}
|
||||
|
||||
2
go.mod
2
go.mod
@@ -2,6 +2,8 @@ module git.wntrmute.dev/mc/mcr
|
||||
|
||||
go 1.25.7
|
||||
|
||||
replace git.wntrmute.dev/mc/mcdsl => ../mcdsl
|
||||
|
||||
require (
|
||||
git.wntrmute.dev/mc/mcdsl v1.2.0
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
|
||||
2
go.sum
2
go.sum
@@ -1,5 +1,3 @@
|
||||
git.wntrmute.dev/mc/mcdsl v1.2.0 h1:41hep7/PNZJfN0SN/nM+rQpyF1GSZcvNNjyVG81DI7U=
|
||||
git.wntrmute.dev/mc/mcdsl v1.2.0/go.mod h1:lXYrAt74ZUix6rx9oVN8d2zH1YJoyp4uxPVKQ+SSxuM=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
|
||||
@@ -14,6 +14,14 @@ type Config struct {
|
||||
mcdslconfig.Base
|
||||
Storage StorageConfig `toml:"storage"`
|
||||
Web WebConfig `toml:"web"`
|
||||
SSO SSOConfig `toml:"sso"`
|
||||
}
|
||||
|
||||
// SSOConfig holds optional SSO redirect settings. When redirect_uri is
|
||||
// non-empty, the web UI redirects to MCIAS for login instead of showing
|
||||
// its own login form.
|
||||
type SSOConfig struct {
|
||||
RedirectURI string `toml:"redirect_uri"`
|
||||
}
|
||||
|
||||
// StorageConfig holds blob/layer storage settings.
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
mcdsso "git.wntrmute.dev/mc/mcdsl/sso"
|
||||
)
|
||||
|
||||
// sessionKey is the context key for the session token.
|
||||
@@ -131,6 +133,50 @@ func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleSSOLogin redirects the user to MCIAS for SSO login.
|
||||
func (s *Server) handleSSOLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if err := mcdsso.RedirectToLogin(w, r, s.ssoClient, "mcr"); err != nil {
|
||||
log.Printf("sso: redirect to login: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSSOCallback exchanges the authorization code for a JWT and sets the session.
|
||||
func (s *Server) handleSSOCallback(w http.ResponseWriter, r *http.Request) {
|
||||
token, returnTo, err := mcdsso.HandleCallback(w, r, s.ssoClient, "mcr")
|
||||
if err != nil {
|
||||
log.Printf("sso: callback: %v", err)
|
||||
http.Error(w, "Login failed. Please try again.", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate the token to check roles. Guest accounts are not
|
||||
// permitted to use the web interface.
|
||||
roles, err := s.validateFn(token)
|
||||
if err != nil {
|
||||
log.Printf("sso: token validation failed: %v", err)
|
||||
http.Error(w, "Login failed. Please try again.", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if slices.Contains(roles, "guest") {
|
||||
log.Printf("sso: login denied for guest user")
|
||||
http.Error(w, "Guest accounts are not permitted to access the web interface.", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "mcr_session",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
|
||||
http.Redirect(w, r, returnTo, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleLogout clears the session and redirects to login.
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
mcdsso "git.wntrmute.dev/mc/mcdsl/sso"
|
||||
mcrv1 "git.wntrmute.dev/mc/mcr/gen/mcr/v1"
|
||||
"git.wntrmute.dev/mc/mcr/web"
|
||||
)
|
||||
@@ -35,6 +36,7 @@ type Server struct {
|
||||
loginFn LoginFunc
|
||||
validateFn ValidateFunc
|
||||
csrfKey []byte // 32-byte key for HMAC signing
|
||||
ssoClient *mcdsso.Client
|
||||
}
|
||||
|
||||
// New creates a new web UI server with the given gRPC clients and login function.
|
||||
@@ -46,6 +48,7 @@ func New(
|
||||
loginFn LoginFunc,
|
||||
validateFn ValidateFunc,
|
||||
csrfKey []byte,
|
||||
ssoClient *mcdsso.Client,
|
||||
) (*Server, error) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
@@ -61,6 +64,7 @@ func New(
|
||||
loginFn: loginFn,
|
||||
validateFn: validateFn,
|
||||
csrfKey: csrfKey,
|
||||
ssoClient: ssoClient,
|
||||
}
|
||||
|
||||
s.router = s.buildRouter()
|
||||
@@ -89,8 +93,13 @@ func (s *Server) buildRouter() chi.Router {
|
||||
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||
|
||||
// Public routes (no session required).
|
||||
r.Get("/login", s.handleLoginPage)
|
||||
r.Post("/login", s.handleLoginSubmit)
|
||||
if s.ssoClient != nil {
|
||||
r.Get("/login", s.handleSSOLogin)
|
||||
r.Get("/sso/callback", s.handleSSOCallback)
|
||||
} else {
|
||||
r.Get("/login", s.handleLoginPage)
|
||||
r.Post("/login", s.handleLoginSubmit)
|
||||
}
|
||||
r.Get("/logout", s.handleLogout)
|
||||
|
||||
// Protected routes (session required).
|
||||
|
||||
@@ -213,6 +213,7 @@ func setupTestEnv(t *testing.T) *testEnv {
|
||||
loginFn,
|
||||
validateFn,
|
||||
csrfKey,
|
||||
nil, // SSO client (nil = use direct login form for tests)
|
||||
)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
|
||||
304
vendor/git.wntrmute.dev/mc/mcdsl/sso/sso.go
vendored
Normal file
304
vendor/git.wntrmute.dev/mc/mcdsl/sso/sso.go
vendored
Normal file
@@ -0,0 +1,304 @@
|
||||
// Package sso provides an SSO redirect client for Metacircular web services.
|
||||
//
|
||||
// Services redirect unauthenticated users to MCIAS for login. After
|
||||
// authentication, MCIAS redirects back with an authorization code that
|
||||
// the service exchanges for a JWT token. This package handles the
|
||||
// redirect, state management, and code exchange.
|
||||
//
|
||||
// Security design:
|
||||
// - State cookies use SameSite=Lax (not Strict) because the redirect from
|
||||
// MCIAS back to the service is a cross-site navigation.
|
||||
// - State is a 256-bit random value stored in an HttpOnly cookie.
|
||||
// - Return-to URLs are stored in a separate cookie so MCIAS never sees them.
|
||||
// - The code exchange is a server-to-server HTTPS call (TLS 1.3 minimum).
|
||||
package sso
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
stateBytes = 32 // 256 bits
|
||||
stateCookieAge = 5 * 60 // 5 minutes in seconds
|
||||
)
|
||||
|
||||
// Config holds the SSO client configuration. The values must match the
|
||||
// SSO client registration in MCIAS config.
|
||||
type Config struct {
|
||||
// MciasURL is the base URL of the MCIAS server.
|
||||
MciasURL string
|
||||
|
||||
// ClientID is the registered SSO client identifier.
|
||||
ClientID string
|
||||
|
||||
// RedirectURI is the callback URL that MCIAS redirects to after login.
|
||||
// Must exactly match the redirect_uri registered in MCIAS config.
|
||||
RedirectURI string
|
||||
|
||||
// CACert is an optional path to a PEM-encoded CA certificate for
|
||||
// verifying the MCIAS server's TLS certificate.
|
||||
CACert string
|
||||
}
|
||||
|
||||
// Client handles the SSO redirect flow with MCIAS.
|
||||
type Client struct {
|
||||
cfg Config
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// New creates an SSO client. TLS 1.3 is required for all HTTPS
|
||||
// connections to MCIAS.
|
||||
func New(cfg Config) (*Client, error) {
|
||||
if cfg.MciasURL == "" {
|
||||
return nil, fmt.Errorf("sso: mcias_url is required")
|
||||
}
|
||||
if cfg.ClientID == "" {
|
||||
return nil, fmt.Errorf("sso: client_id is required")
|
||||
}
|
||||
if cfg.RedirectURI == "" {
|
||||
return nil, fmt.Errorf("sso: redirect_uri is required")
|
||||
}
|
||||
|
||||
transport := &http.Transport{}
|
||||
|
||||
if !strings.HasPrefix(cfg.MciasURL, "http://") {
|
||||
tlsCfg := &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}
|
||||
|
||||
if cfg.CACert != "" {
|
||||
pem, err := os.ReadFile(cfg.CACert) //nolint:gosec // CA cert path from operator config
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sso: read CA cert %s: %w", cfg.CACert, err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("sso: no valid certificates in %s", cfg.CACert)
|
||||
}
|
||||
tlsCfg.RootCAs = pool
|
||||
}
|
||||
|
||||
transport.TLSClientConfig = tlsCfg
|
||||
}
|
||||
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
httpClient: &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AuthorizeURL returns the MCIAS authorize URL with the given state parameter.
|
||||
func (c *Client) AuthorizeURL(state string) string {
|
||||
base := strings.TrimRight(c.cfg.MciasURL, "/")
|
||||
return base + "/sso/authorize?" + url.Values{
|
||||
"client_id": {c.cfg.ClientID},
|
||||
"redirect_uri": {c.cfg.RedirectURI},
|
||||
"state": {state},
|
||||
}.Encode()
|
||||
}
|
||||
|
||||
// ExchangeCode exchanges an authorization code for a JWT token by calling
|
||||
// MCIAS POST /v1/sso/token.
|
||||
func (c *Client) ExchangeCode(ctx context.Context, code string) (token string, expiresAt time.Time, err error) {
|
||||
reqBody, _ := json.Marshal(map[string]string{
|
||||
"code": code,
|
||||
"client_id": c.cfg.ClientID,
|
||||
"redirect_uri": c.cfg.RedirectURI,
|
||||
})
|
||||
|
||||
base := strings.TrimRight(c.cfg.MciasURL, "/")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
base+"/v1/sso/token", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("sso: build exchange request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("sso: MCIAS exchange: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("sso: read exchange response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", time.Time{}, fmt.Errorf("sso: exchange failed (HTTP %d): %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("sso: decode exchange response: %w", err)
|
||||
}
|
||||
|
||||
exp, parseErr := time.Parse(time.RFC3339, result.ExpiresAt)
|
||||
if parseErr != nil {
|
||||
exp = time.Now().Add(1 * time.Hour)
|
||||
}
|
||||
|
||||
return result.Token, exp, nil
|
||||
}
|
||||
|
||||
// GenerateState returns a cryptographically random hex-encoded state string.
|
||||
func GenerateState() (string, error) {
|
||||
raw := make([]byte, stateBytes)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("sso: generate state: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
// StateCookieName returns the cookie name used for SSO state for a given
|
||||
// service cookie prefix (e.g., "mcr" → "mcr_sso_state").
|
||||
func StateCookieName(prefix string) string {
|
||||
return prefix + "_sso_state"
|
||||
}
|
||||
|
||||
// ReturnToCookieName returns the cookie name used for SSO return-to URL
|
||||
// (e.g., "mcr" → "mcr_sso_return").
|
||||
func ReturnToCookieName(prefix string) string {
|
||||
return prefix + "_sso_return"
|
||||
}
|
||||
|
||||
// SetStateCookie stores the SSO state in a short-lived cookie.
|
||||
//
|
||||
// Security: SameSite=Lax is required because the redirect from MCIAS back to
|
||||
// the service is a cross-site top-level navigation. SameSite=Strict cookies
|
||||
// would not be sent on that redirect.
|
||||
func SetStateCookie(w http.ResponseWriter, prefix, state string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: StateCookieName(prefix),
|
||||
Value: state,
|
||||
Path: "/",
|
||||
MaxAge: stateCookieAge,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// ValidateStateCookie compares the state query parameter against the state
|
||||
// cookie. If they match, the cookie is cleared and nil is returned.
|
||||
func ValidateStateCookie(w http.ResponseWriter, r *http.Request, prefix, queryState string) error {
|
||||
c, err := r.Cookie(StateCookieName(prefix))
|
||||
if err != nil || c.Value == "" {
|
||||
return fmt.Errorf("sso: missing state cookie")
|
||||
}
|
||||
|
||||
if c.Value != queryState {
|
||||
return fmt.Errorf("sso: state mismatch")
|
||||
}
|
||||
|
||||
// Clear the state cookie (single-use).
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: StateCookieName(prefix),
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetReturnToCookie stores the current request path so the service can
|
||||
// redirect back to it after SSO login completes.
|
||||
func SetReturnToCookie(w http.ResponseWriter, r *http.Request, prefix string) {
|
||||
path := r.URL.Path
|
||||
if path == "" || path == "/login" || path == "/sso/callback" {
|
||||
path = "/"
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: ReturnToCookieName(prefix),
|
||||
Value: path,
|
||||
Path: "/",
|
||||
MaxAge: stateCookieAge,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// ConsumeReturnToCookie reads and clears the return-to cookie, returning
|
||||
// the path. Returns "/" if the cookie is missing or empty.
|
||||
func ConsumeReturnToCookie(w http.ResponseWriter, r *http.Request, prefix string) string {
|
||||
c, err := r.Cookie(ReturnToCookieName(prefix))
|
||||
path := "/"
|
||||
if err == nil && c.Value != "" {
|
||||
path = c.Value
|
||||
}
|
||||
|
||||
// Clear the cookie.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: ReturnToCookieName(prefix),
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
// RedirectToLogin generates a state, sets the state and return-to cookies,
|
||||
// and redirects the user to the MCIAS authorize URL.
|
||||
func RedirectToLogin(w http.ResponseWriter, r *http.Request, client *Client, cookiePrefix string) error {
|
||||
state, err := GenerateState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
SetStateCookie(w, cookiePrefix, state)
|
||||
SetReturnToCookie(w, r, cookiePrefix)
|
||||
http.Redirect(w, r, client.AuthorizeURL(state), http.StatusFound)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleCallback validates the state, exchanges the authorization code for
|
||||
// a JWT, and returns the token and the return-to path. The caller should
|
||||
// set the session cookie with the returned token.
|
||||
func HandleCallback(w http.ResponseWriter, r *http.Request, client *Client, cookiePrefix string) (token, returnTo string, err error) {
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
if code == "" || state == "" {
|
||||
return "", "", fmt.Errorf("sso: missing code or state parameter")
|
||||
}
|
||||
|
||||
if err := ValidateStateCookie(w, r, cookiePrefix, state); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
token, _, err = client.ExchangeCode(r.Context(), code)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
returnTo = ConsumeReturnToCookie(w, r, cookiePrefix)
|
||||
return token, returnTo, nil
|
||||
}
|
||||
4
vendor/modules.txt
vendored
4
vendor/modules.txt
vendored
@@ -1,9 +1,10 @@
|
||||
# git.wntrmute.dev/mc/mcdsl v1.2.0
|
||||
# git.wntrmute.dev/mc/mcdsl v1.2.0 => ../mcdsl
|
||||
## explicit; go 1.25.7
|
||||
git.wntrmute.dev/mc/mcdsl/auth
|
||||
git.wntrmute.dev/mc/mcdsl/config
|
||||
git.wntrmute.dev/mc/mcdsl/db
|
||||
git.wntrmute.dev/mc/mcdsl/grpcserver
|
||||
git.wntrmute.dev/mc/mcdsl/sso
|
||||
# github.com/dustin/go-humanize v1.0.1
|
||||
## explicit; go 1.16
|
||||
github.com/dustin/go-humanize
|
||||
@@ -197,3 +198,4 @@ modernc.org/memory
|
||||
modernc.org/sqlite
|
||||
modernc.org/sqlite/lib
|
||||
modernc.org/sqlite/vtab
|
||||
# git.wntrmute.dev/mc/mcdsl => ../mcdsl
|
||||
|
||||
Reference in New Issue
Block a user