From 21bf56dc201049e6f52b63dd1bd31bd179276fb6 Mon Sep 17 00:00:00 2001 From: m Date: Tue, 14 Apr 2026 16:34:17 +0200 Subject: [PATCH] feat: add Supabase password auth with @hoganlovells.com restriction Go server authenticates against Supabase GoTrue (youpc instance) using email+password. Login page with login/register tabs, domain restricted to @hoganlovells.com. Auth middleware protects all routes, refreshes expired tokens via refresh_token cookie. Lime green branding. - internal/auth: Supabase client (sign in, sign up, refresh, sign out), JWT expiry decode, auth middleware, cookie management - internal/handlers: login/register/logout handlers, per-page template parsing to avoid content block collisions - templates/login.html: tabbed login/register form - 30-day HTTP-only session cookies with SameSite=Lax - SUPABASE_URL and SUPABASE_ANON_KEY env vars in docker-compose --- cmd/server/main.go | 11 +- docker-compose.yml | 2 + internal/auth/auth.go | 265 ++++++++++++++++++++++++++++++++++ internal/handlers/auth.go | 174 ++++++++++++++++++++++ internal/handlers/handlers.go | 56 +++++-- static/css/style.css | 157 +++++++++++++++++++- templates/index.html | 15 +- templates/login.html | 71 +++++++++ 8 files changed, 729 insertions(+), 22 deletions(-) create mode 100644 internal/auth/auth.go create mode 100644 internal/handlers/auth.go create mode 100644 templates/login.html diff --git a/cmd/server/main.go b/cmd/server/main.go index dadda4e..7dbcdbc 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -5,6 +5,7 @@ import ( "net/http" "os" + "mgit.msbls.de/m/patholo/internal/auth" "mgit.msbls.de/m/patholo/internal/handlers" ) @@ -14,8 +15,16 @@ func main() { port = "8080" } + supabaseURL := os.Getenv("SUPABASE_URL") + supabaseAnonKey := os.Getenv("SUPABASE_ANON_KEY") + if supabaseURL == "" || supabaseAnonKey == "" { + log.Fatal("SUPABASE_URL and SUPABASE_ANON_KEY must be set") + } + + client := auth.NewClient(supabaseURL, supabaseAnonKey) + mux := http.NewServeMux() - handlers.Register(mux) + handlers.Register(mux, client) log.Printf("patholo server starting on :%s", port) if err := http.ListenAndServe(":"+port, mux); err != nil { diff --git a/docker-compose.yml b/docker-compose.yml index 5d90047..aaf0ac7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,4 +5,6 @@ services: - "8080" environment: - PORT=8080 + - SUPABASE_URL=${SUPABASE_URL} + - SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY} restart: unless-stopped diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..dffcaba --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,265 @@ +package auth + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" +) + +const ( + SessionCookieName = "patholo_session" + RefreshCookieName = "patholo_refresh" + CookieMaxAge = 30 * 24 * 60 * 60 // 30 days +) + +type Client struct { + URL string + AnonKey string + HTTP *http.Client +} + +func NewClient(supabaseURL, anonKey string) *Client { + return &Client{ + URL: strings.TrimRight(supabaseURL, "/"), + AnonKey: anonKey, + HTTP: &http.Client{Timeout: 10 * time.Second}, + } +} + +type TokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` +} + +// SignIn authenticates a user with email and password. +func (c *Client) SignIn(email, password string) (*TokenResponse, error) { + return c.tokenRequest("password", map[string]string{ + "email": email, + "password": password, + }) +} + +// RefreshSession exchanges a refresh token for a new access token. +func (c *Client) RefreshSession(refreshToken string) (*TokenResponse, error) { + return c.tokenRequest("refresh_token", map[string]string{ + "refresh_token": refreshToken, + }) +} + +func (c *Client) tokenRequest(grantType string, body map[string]string) (*TokenResponse, error) { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal: %w", err) + } + + endpoint := fmt.Sprintf("%s/auth/v1/token?grant_type=%s", c.URL, grantType) + req, err := http.NewRequest("POST", endpoint, bytes.NewReader(jsonBody)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("apikey", c.AnonKey) + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status %d: %s", resp.StatusCode, parseErrorMessage(respBody)) + } + + var result TokenResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + if result.AccessToken == "" { + return nil, errors.New("empty access_token") + } + return &result, nil +} + +// SignUp registers a new user. Returns tokens if auto-confirm is enabled, nil otherwise. +func (c *Client) SignUp(email, password string) (*TokenResponse, error) { + jsonBody, err := json.Marshal(map[string]string{ + "email": email, + "password": password, + }) + if err != nil { + return nil, fmt.Errorf("marshal: %w", err) + } + + endpoint := fmt.Sprintf("%s/auth/v1/signup", c.URL) + req, err := http.NewRequest("POST", endpoint, bytes.NewReader(jsonBody)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("apikey", c.AnonKey) + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("status %d: %s", resp.StatusCode, parseErrorMessage(respBody)) + } + + var result TokenResponse + if err := json.Unmarshal(respBody, &result); err != nil || result.AccessToken == "" { + return nil, nil // registered but no auto-login (email confirmation pending) + } + return &result, nil +} + +// SignOut invalidates the user's session on Supabase. +func (c *Client) SignOut(accessToken string) { + endpoint := fmt.Sprintf("%s/auth/v1/logout", c.URL) + req, err := http.NewRequest("POST", endpoint, nil) + if err != nil { + return + } + req.Header.Set("apikey", c.AnonKey) + req.Header.Set("Authorization", "Bearer "+accessToken) + + resp, err := c.HTTP.Do(req) + if err != nil { + return + } + resp.Body.Close() +} + +// DecodeJWTExpiry reads the exp claim from a JWT without signature verification. +func DecodeJWTExpiry(token string) (time.Time, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return time.Time{}, errors.New("invalid token format") + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return time.Time{}, fmt.Errorf("decode payload: %w", err) + } + + var claims struct { + Exp float64 `json:"exp"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return time.Time{}, fmt.Errorf("parse claims: %w", err) + } + if claims.Exp == 0 { + return time.Time{}, errors.New("no exp claim") + } + return time.Unix(int64(claims.Exp), 0), nil +} + +// Middleware requires a valid session for protected routes. +func (c *Client) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sessionCookie, err := r.Cookie(SessionCookieName) + if err != nil || sessionCookie.Value == "" { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + exp, err := DecodeJWTExpiry(sessionCookie.Value) + if err != nil { + ClearAuthCookies(w) + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + if time.Now().After(exp) { + // Access token expired — try refresh + refreshCookie, err := r.Cookie(RefreshCookieName) + if err != nil || refreshCookie.Value == "" { + ClearAuthCookies(w) + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + tokens, err := c.RefreshSession(refreshCookie.Value) + if err != nil { + log.Printf("token refresh failed: %v", err) + ClearAuthCookies(w) + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + SetAuthCookies(w, r, tokens) + } + + next.ServeHTTP(w, r) + }) +} + +// SetAuthCookies writes session and refresh token cookies. +func SetAuthCookies(w http.ResponseWriter, r *http.Request, tokens *TokenResponse) { + secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" + for _, c := range []*http.Cookie{ + {Name: SessionCookieName, Value: tokens.AccessToken}, + {Name: RefreshCookieName, Value: tokens.RefreshToken}, + } { + http.SetCookie(w, &http.Cookie{ + Name: c.Name, + Value: c.Value, + Path: "/", + MaxAge: CookieMaxAge, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: secure, + }) + } +} + +// ClearAuthCookies removes session and refresh token cookies. +func ClearAuthCookies(w http.ResponseWriter) { + for _, name := range []string{SessionCookieName, RefreshCookieName} { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + }) + } +} + +func parseErrorMessage(body []byte) string { + var resp struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + Message string `json:"message"` + Msg string `json:"msg"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return string(body) + } + for _, msg := range []string{resp.ErrorDescription, resp.Message, resp.Msg, resp.Error} { + if msg != "" { + return msg + } + } + return string(body) +} diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go new file mode 100644 index 0000000..ac7ae42 --- /dev/null +++ b/internal/handlers/auth.go @@ -0,0 +1,174 @@ +package handlers + +import ( + "log" + "net/http" + "strings" + "time" + + "mgit.msbls.de/m/patholo/internal/auth" +) + +type loginData struct { + Mode string + Error string + Success string + Email string +} + +func handleLogin(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + handleLoginPage(w, r) + case http.MethodPost: + handleLoginSubmit(w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } +} + +func handleLoginPage(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie(auth.SessionCookieName); err == nil && cookie.Value != "" { + if exp, err := auth.DecodeJWTExpiry(cookie.Value); err == nil && time.Now().Before(exp) { + http.Redirect(w, r, "/", http.StatusFound) + return + } + } + + data := loginData{ + Mode: r.URL.Query().Get("mode"), + Error: r.URL.Query().Get("error"), + } + if data.Mode == "" { + data.Mode = "login" + } + renderPage(w, "login.html", data) +} + +func handleLoginSubmit(w http.ResponseWriter, r *http.Request) { + email := strings.TrimSpace(r.FormValue("email")) + password := r.FormValue("password") + + if email == "" || password == "" { + renderPage(w, "login.html", loginData{ + Mode: "login", + Error: "Bitte E-Mail und Passwort eingeben.", + Email: email, + }) + return + } + + if !isHoganLovellsEmail(email) { + renderPage(w, "login.html", loginData{ + Mode: "login", + Error: "Zugang nur für @hoganlovells.com E-Mail-Adressen.", + Email: email, + }) + return + } + + tokens, err := authClient.SignIn(email, password) + if err != nil { + log.Printf("sign in failed for %s: %v", email, err) + errMsg := "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut." + if strings.Contains(err.Error(), "Invalid login credentials") { + errMsg = "Ungültige E-Mail-Adresse oder Passwort." + } + renderPage(w, "login.html", loginData{ + Mode: "login", + Error: errMsg, + Email: email, + }) + return + } + + auth.SetAuthCookies(w, r, tokens) + http.Redirect(w, r, "/", http.StatusFound) +} + +func handleRegister(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + email := strings.TrimSpace(r.FormValue("email")) + password := r.FormValue("password") + confirm := r.FormValue("confirm") + + if email == "" || password == "" { + renderPage(w, "login.html", loginData{ + Mode: "register", + Error: "Bitte alle Felder ausfüllen.", + Email: email, + }) + return + } + + if password != confirm { + renderPage(w, "login.html", loginData{ + Mode: "register", + Error: "Passwörter stimmen nicht überein.", + Email: email, + }) + return + } + + if len(password) < 8 { + renderPage(w, "login.html", loginData{ + Mode: "register", + Error: "Passwort muss mindestens 8 Zeichen lang sein.", + Email: email, + }) + return + } + + if !isHoganLovellsEmail(email) { + renderPage(w, "login.html", loginData{ + Mode: "register", + Error: "Registrierung nur für @hoganlovells.com E-Mail-Adressen.", + Email: email, + }) + return + } + + tokens, err := authClient.SignUp(email, password) + if err != nil { + log.Printf("sign up failed for %s: %v", email, err) + errMsg := "Registrierung fehlgeschlagen. Bitte versuchen Sie es erneut." + if strings.Contains(err.Error(), "already registered") || strings.Contains(err.Error(), "already been registered") { + errMsg = "Ein Account mit dieser E-Mail existiert bereits." + } + renderPage(w, "login.html", loginData{ + Mode: "register", + Error: errMsg, + Email: email, + }) + return + } + + if tokens != nil { + auth.SetAuthCookies(w, r, tokens) + http.Redirect(w, r, "/", http.StatusFound) + return + } + + renderPage(w, "login.html", loginData{ + Mode: "login", + Success: "Account erstellt. Bitte melden Sie sich an.", + Email: email, + }) +} + +func handleLogout(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie(auth.SessionCookieName); err == nil { + authClient.SignOut(cookie.Value) + } + auth.ClearAuthCookies(w) + http.Redirect(w, r, "/login", http.StatusFound) +} + +func isHoganLovellsEmail(email string) bool { + parts := strings.SplitN(email, "@", 2) + return len(parts) == 2 && strings.EqualFold(parts[1], "hoganlovells.com") +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index db3aec2..316e8d2 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -5,19 +5,55 @@ import ( "log" "net/http" "path/filepath" + + "mgit.msbls.de/m/patholo/internal/auth" ) -var templates *template.Template +var ( + templates map[string]*template.Template + authClient *auth.Client +) -func Register(mux *http.ServeMux) { - var err error - templates, err = template.ParseGlob(filepath.Join("templates", "*.html")) - if err != nil { - log.Fatalf("failed to parse templates: %v", err) +func Register(mux *http.ServeMux, client *auth.Client) { + authClient = client + + // Parse each page template separately so "content" blocks don't collide + templates = make(map[string]*template.Template) + for _, page := range []string{"index.html", "login.html"} { + t, err := template.ParseFiles( + filepath.Join("templates", "base.html"), + filepath.Join("templates", page), + ) + if err != nil { + log.Fatalf("parse template %s: %v", page, err) + } + templates[page] = t } + // Public routes mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) - mux.HandleFunc("/", handleIndex) + mux.HandleFunc("/login", handleLogin) + mux.HandleFunc("/register", handleRegister) + mux.HandleFunc("/logout", handleLogout) + + // Protected routes — everything else goes through auth middleware + protected := http.NewServeMux() + protected.HandleFunc("/", handleIndex) + mux.Handle("/", client.Middleware(protected)) +} + +func renderPage(w http.ResponseWriter, name string, data interface{}) { + t, ok := templates[name] + if !ok { + log.Printf("template %s not found", name) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := t.ExecuteTemplate(w, name, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } } func handleIndex(w http.ResponseWriter, r *http.Request) { @@ -25,9 +61,5 @@ func handleIndex(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := templates.ExecuteTemplate(w, "index.html", nil); err != nil { - log.Printf("template error: %v", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } + renderPage(w, "index.html", nil) } diff --git a/static/css/style.css b/static/css/style.css index 0e2f9b5..c43600a 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -5,10 +5,10 @@ --color-surface: #ffffff; --color-text: #1a1a2e; --color-text-muted: #64647a; - --color-accent: #1b365d; /* HL navy */ - --color-accent-light: #2a5298; + --color-accent: #65a30d; /* lime green */ + --color-accent-light: #84cc16; --color-border: #e5e5ed; - --color-hero-bg: #1b365d; + --color-hero-bg: #1a2e1a; /* dark forest */ --color-hero-text: #ffffff; --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; --font-mono: "JetBrains Mono", "Fira Code", monospace; @@ -85,6 +85,22 @@ main { font-size: 1.3rem; } +.nav-right { + display: flex; + align-items: center; + gap: 1.25rem; +} + +.nav-logout { + font-size: 0.8rem; + color: var(--color-text-muted); + text-decoration: none; +} + +.nav-logout:hover { + color: var(--color-accent); +} + .nav-lang { font-size: 0.8rem; letter-spacing: 0.05em; @@ -250,6 +266,141 @@ main { text-align: center; } +/* ─── Login ─── */ + +.login-main { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 3rem 1.5rem; +} + +.login-card { + width: 100%; + max-width: 400px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 2.5rem; + box-shadow: var(--shadow-md); +} + +.login-tabs { + display: flex; + margin-bottom: 1.75rem; + border-bottom: 1px solid var(--color-border); +} + +.login-tab { + flex: 1; + padding: 0.6rem 0; + background: none; + border: none; + border-bottom: 2px solid transparent; + font-family: var(--font-sans); + font-size: 0.9rem; + font-weight: 500; + color: var(--color-text-muted); + cursor: pointer; + transition: color 0.15s ease, border-color 0.15s ease; +} + +.login-tab:hover { + color: var(--color-text); +} + +.login-tab.active { + color: var(--color-accent); + border-bottom-color: var(--color-accent); +} + +.login-error { + background: #fef2f2; + color: #991b1b; + border: 1px solid #fecaca; + border-radius: var(--radius); + padding: 0.75rem 1rem; + font-size: 0.85rem; + margin-bottom: 1.25rem; + line-height: 1.5; +} + +.login-success { + background: #f0fdf4; + color: #166534; + border: 1px solid #bbf7d0; + border-radius: var(--radius); + padding: 0.75rem 1rem; + font-size: 0.85rem; + margin-bottom: 1.25rem; + line-height: 1.5; +} + +.login-form { + display: flex; + flex-direction: column; +} + +.login-label { + font-size: 0.85rem; + font-weight: 500; + color: var(--color-text); + margin-bottom: 0.35rem; + margin-top: 0.75rem; +} + +.login-label:first-child { + margin-top: 0; +} + +.login-input { + font-family: var(--font-sans); + font-size: 0.92rem; + padding: 0.6rem 0.8rem; + border: 1px solid var(--color-border); + border-radius: var(--radius); + outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + color: var(--color-text); + background: var(--color-bg); +} + +.login-input:focus { + border-color: var(--color-accent); + box-shadow: 0 0 0 3px rgba(101, 163, 13, 0.15); +} + +.login-input::placeholder { + color: var(--color-text-muted); + opacity: 0.6; +} + +.login-button { + font-family: var(--font-sans); + font-size: 0.92rem; + font-weight: 600; + padding: 0.65rem 1rem; + margin-top: 1.25rem; + border: none; + border-radius: var(--radius); + background: var(--color-accent); + color: #ffffff; + cursor: pointer; + transition: background 0.15s ease; +} + +.login-button:hover { + background: var(--color-accent-light); +} + +.login-hint { + font-size: 0.78rem; + color: var(--color-text-muted); + text-align: center; + margin-top: 1.5rem; +} + /* ─── Responsive ─── */ @media (max-width: 768px) { diff --git a/templates/index.html b/templates/index.html index ee66af7..d4cb813 100644 --- a/templates/index.html +++ b/templates/index.html @@ -6,14 +6,17 @@
diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..ee8b0bd --- /dev/null +++ b/templates/login.html @@ -0,0 +1,71 @@ +{{define "login.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +
+ +
+ +
+ +
+ +
+
+

© 2026 patholo — Internal use only. Hogan Lovells Patent Practice.

+
+
+ + +{{end}}