@@ -0,0 +1,358 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AuthMode string
|
||||
|
||||
const (
|
||||
AuthModeLocal AuthMode = "local"
|
||||
AuthModeOIDC AuthMode = "oidc"
|
||||
)
|
||||
|
||||
type AuthConfig struct {
|
||||
Mode AuthMode
|
||||
Issuer string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
Scopes []string
|
||||
SessionSecret []byte
|
||||
}
|
||||
|
||||
type oidcDiscovery struct {
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
UserInfoEndpoint string `json:"userinfo_endpoint"`
|
||||
Issuer string `json:"issuer"`
|
||||
}
|
||||
|
||||
type oauthTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
IDToken string `json:"id_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
type userClaims struct {
|
||||
Subject string `json:"sub"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type sessionData struct {
|
||||
Username string `json:"username"`
|
||||
Subject string `json:"sub,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Expires int64 `json:"exp"`
|
||||
}
|
||||
|
||||
func AuthConfigFromEnv(logger *slog.Logger) (AuthConfig, error) {
|
||||
mode := AuthMode(strings.ToLower(strings.TrimSpace(getenv("AUTH_MODE", "local"))))
|
||||
if mode != AuthModeLocal && mode != AuthModeOIDC {
|
||||
return AuthConfig{}, fmt.Errorf("unsupported AUTH_MODE %q", mode)
|
||||
}
|
||||
|
||||
secret := strings.TrimSpace(os.Getenv("SESSION_SECRET"))
|
||||
var secretBytes []byte
|
||||
if secret == "" {
|
||||
secretBytes = make([]byte, 32)
|
||||
if _, err := rand.Read(secretBytes); err != nil {
|
||||
return AuthConfig{}, err
|
||||
}
|
||||
logger.Warn("SESSION_SECRET is not set; generated a temporary secret. Existing sessions will be invalid after restart")
|
||||
} else {
|
||||
decoded, err := base64.StdEncoding.DecodeString(secret)
|
||||
if err == nil && len(decoded) >= 32 {
|
||||
secretBytes = decoded
|
||||
} else {
|
||||
secretBytes = []byte(secret)
|
||||
}
|
||||
if len(secretBytes) < 32 {
|
||||
return AuthConfig{}, errors.New("SESSION_SECRET must be at least 32 bytes or a base64 encoded 32 byte value")
|
||||
}
|
||||
}
|
||||
|
||||
cfg := AuthConfig{
|
||||
Mode: mode,
|
||||
Issuer: strings.TrimRight(strings.TrimSpace(os.Getenv("OIDC_ISSUER")), "/"),
|
||||
ClientID: strings.TrimSpace(os.Getenv("OIDC_CLIENT_ID")),
|
||||
ClientSecret: strings.TrimSpace(os.Getenv("OIDC_CLIENT_SECRET")),
|
||||
RedirectURL: strings.TrimSpace(os.Getenv("OIDC_REDIRECT_URL")),
|
||||
Scopes: splitScopes(getenv("OIDC_SCOPES", "openid profile email")),
|
||||
SessionSecret: secretBytes,
|
||||
}
|
||||
if cfg.Mode == AuthModeOIDC {
|
||||
if cfg.Issuer == "" || cfg.ClientID == "" || cfg.ClientSecret == "" || cfg.RedirectURL == "" {
|
||||
return AuthConfig{}, errors.New("AUTH_MODE=oidc requires OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET and OIDC_REDIRECT_URL")
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *Server) discoverOIDC() error {
|
||||
if s.auth.Mode != AuthModeOIDC {
|
||||
return nil
|
||||
}
|
||||
discoveryURL := s.auth.Issuer + "/.well-known/openid-configuration"
|
||||
req, err := http.NewRequest(http.MethodGet, discoveryURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("OIDC discovery failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return fmt.Errorf("OIDC discovery returned %s", resp.Status)
|
||||
}
|
||||
var d oidcDiscovery
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&d); err != nil {
|
||||
return err
|
||||
}
|
||||
if d.AuthorizationEndpoint == "" || d.TokenEndpoint == "" || d.UserInfoEndpoint == "" {
|
||||
return errors.New("OIDC discovery response is missing required endpoints")
|
||||
}
|
||||
s.oidc = d
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleOIDCLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if s.auth.Mode != AuthModeOIDC {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
state, err := randomString(32)
|
||||
if err != nil {
|
||||
http.Error(w, "could not create login state", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
setCookie(w, &http.Cookie{Name: "oauth_state", Value: state, MaxAge: 10 * 60, Path: "/auth", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: isSecure(r)})
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("response_type", "code")
|
||||
q.Set("client_id", s.auth.ClientID)
|
||||
q.Set("redirect_uri", s.auth.RedirectURL)
|
||||
q.Set("scope", strings.Join(s.auth.Scopes, " "))
|
||||
q.Set("state", state)
|
||||
authURL := s.oidc.AuthorizationEndpoint + "?" + q.Encode()
|
||||
http.Redirect(w, r, authURL, http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if s.auth.Mode != AuthModeOIDC {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if errText := r.URL.Query().Get("error"); errText != "" {
|
||||
s.renderStatus(w, r, http.StatusUnauthorized, "login.html", map[string]any{"Title": "Login", "AuthMode": s.auth.Mode, "Error": "Login abgebrochen: " + errText})
|
||||
return
|
||||
}
|
||||
stateCookie, err := r.Cookie("oauth_state")
|
||||
if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") {
|
||||
http.Error(w, "invalid oauth state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
clearCookie(w, "oauth_state", "/auth")
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
http.Error(w, "missing authorization code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tok, err := s.exchangeCode(r.Context(), code)
|
||||
if err != nil {
|
||||
s.logger.Error("oauth token exchange", "error", err)
|
||||
http.Error(w, "token exchange failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
claims, err := s.fetchUserInfo(r.Context(), tok.AccessToken)
|
||||
if err != nil {
|
||||
s.logger.Error("oauth userinfo", "error", err)
|
||||
http.Error(w, "could not fetch user info", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
username := normalizeUsername(claims)
|
||||
if username == "" {
|
||||
http.Error(w, "identity provider did not return a usable username", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if err := s.setSession(w, r, sessionData{Username: username, Subject: claims.Subject, Email: claims.Email, Expires: time.Now().Add(30 * 24 * time.Hour).Unix()}); err != nil {
|
||||
http.Error(w, "could not create session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/rooms", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) exchangeCode(ctx context.Context, code string) (oauthTokenResponse, error) {
|
||||
// Keep the public signature simple for Go 1.22 without adding external OAuth dependencies.
|
||||
values := url.Values{}
|
||||
values.Set("grant_type", "authorization_code")
|
||||
values.Set("code", code)
|
||||
values.Set("redirect_uri", s.auth.RedirectURL)
|
||||
values.Set("client_id", s.auth.ClientID)
|
||||
values.Set("client_secret", s.auth.ClientSecret)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.oidc.TokenEndpoint, strings.NewReader(values.Encode()))
|
||||
if err != nil {
|
||||
return oauthTokenResponse{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return oauthTokenResponse{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return oauthTokenResponse{}, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return oauthTokenResponse{}, fmt.Errorf("token endpoint returned %s: %s", resp.Status, string(body))
|
||||
}
|
||||
var tok oauthTokenResponse
|
||||
if err := json.Unmarshal(body, &tok); err != nil {
|
||||
return oauthTokenResponse{}, err
|
||||
}
|
||||
if tok.AccessToken == "" {
|
||||
return oauthTokenResponse{}, errors.New("token endpoint did not return access_token")
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
func (s *Server) fetchUserInfo(ctx context.Context, accessToken string) (userClaims, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.oidc.UserInfoEndpoint, nil)
|
||||
if err != nil {
|
||||
return userClaims{}, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return userClaims{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return userClaims{}, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return userClaims{}, fmt.Errorf("userinfo endpoint returned %s: %s", resp.Status, string(body))
|
||||
}
|
||||
var claims userClaims
|
||||
if err := json.Unmarshal(body, &claims); err != nil {
|
||||
return userClaims{}, err
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (s *Server) setSession(w http.ResponseWriter, r *http.Request, data sessionData) error {
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := base64.RawURLEncoding.EncodeToString(b)
|
||||
sig := sign(payload, s.auth.SessionSecret)
|
||||
setCookie(w, &http.Cookie{Name: "chat_session", Value: payload + "." + sig, Path: "/", MaxAge: 60 * 60 * 24 * 30, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: isSecure(r)})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) readSession(r *http.Request) (sessionData, bool) {
|
||||
c, err := r.Cookie("chat_session")
|
||||
if err != nil || c.Value == "" {
|
||||
return sessionData{}, false
|
||||
}
|
||||
parts := strings.Split(c.Value, ".")
|
||||
if len(parts) != 2 || !verify(parts[0], parts[1], s.auth.SessionSecret) {
|
||||
return sessionData{}, false
|
||||
}
|
||||
b, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return sessionData{}, false
|
||||
}
|
||||
var data sessionData
|
||||
if err := json.Unmarshal(b, &data); err != nil {
|
||||
return sessionData{}, false
|
||||
}
|
||||
if data.Expires < time.Now().Unix() || strings.TrimSpace(data.Username) == "" {
|
||||
return sessionData{}, false
|
||||
}
|
||||
return data, true
|
||||
}
|
||||
|
||||
func sign(payload string, secret []byte) string {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func verify(payload, got string, secret []byte) bool {
|
||||
expected := sign(payload, secret)
|
||||
return hmac.Equal([]byte(expected), []byte(got))
|
||||
}
|
||||
|
||||
func randomString(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func normalizeUsername(c userClaims) string {
|
||||
for _, v := range []string{c.PreferredUsername, c.Name, c.Email, c.Subject} {
|
||||
v = strings.TrimSpace(v)
|
||||
if v != "" {
|
||||
if strings.Contains(v, "@") {
|
||||
v = strings.Split(v, "@")[0]
|
||||
}
|
||||
if len(v) > 40 {
|
||||
v = v[:40]
|
||||
}
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func splitScopes(s string) []string {
|
||||
fields := strings.Fields(s)
|
||||
if len(fields) == 0 {
|
||||
return []string{"openid", "profile", "email"}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func getenv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func setCookie(w http.ResponseWriter, c *http.Cookie) { http.SetCookie(w, c) }
|
||||
|
||||
func clearCookie(w http.ResponseWriter, name, path string) {
|
||||
http.SetCookie(w, &http.Cookie{Name: name, Value: "", Path: path, MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||
}
|
||||
|
||||
func isSecure(r *http.Request) bool {
|
||||
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
Reference in New Issue
Block a user