feat: Supabase password auth with @hoganlovells.com restriction + lime green branding

This commit is contained in:
m
2026-04-14 16:36:43 +02:00
8 changed files with 729 additions and 22 deletions

View File

@@ -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 {

View File

@@ -5,4 +5,6 @@ services:
- "8080"
environment:
- PORT=8080
- SUPABASE_URL=${SUPABASE_URL}
- SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
restart: unless-stopped

265
internal/auth/auth.go Normal file
View File

@@ -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)
}

174
internal/handlers/auth.go Normal file
View File

@@ -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")
}

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -6,14 +6,17 @@
<header class="header">
<div class="container">
<nav class="nav">
<div class="logo">
<a href="/" class="logo">
<span class="logo-mark">p</span>
<span class="logo-text">patholo</span>
</div>
<div class="nav-lang">
<span class="lang-active">DE</span>
<span class="lang-sep">/</span>
<span class="lang-inactive">EN</span>
</a>
<div class="nav-right">
<a href="/logout" class="nav-logout">Abmelden</a>
<div class="nav-lang">
<span class="lang-active">DE</span>
<span class="lang-sep">/</span>
<span class="lang-inactive">EN</span>
</div>
</div>
</nav>
</div>

71
templates/login.html Normal file
View File

@@ -0,0 +1,71 @@
{{define "login.html"}}
{{template "base" .}}
{{end}}
{{define "content"}}
<header class="header">
<div class="container">
<nav class="nav">
<a href="/" class="logo">
<span class="logo-mark">p</span>
<span class="logo-text">patholo</span>
</a>
</nav>
</div>
</header>
<main class="login-main">
<div class="login-card">
<div class="login-tabs">
<button class="login-tab{{if ne .Mode "register"}} active{{end}}" data-tab="login">Anmelden</button>
<button class="login-tab{{if eq .Mode "register"}} active{{end}}" data-tab="register">Registrieren</button>
</div>
{{if .Error}}
<div class="login-error">{{.Error}}</div>
{{end}}
{{if .Success}}
<div class="login-success">{{.Success}}</div>
{{end}}
<form method="POST" action="/login" class="login-form" id="login-form"{{if eq .Mode "register"}} style="display:none"{{end}}>
<label for="login-email" class="login-label">E-Mail</label>
<input type="email" id="login-email" name="email" value="{{.Email}}" placeholder="name@hoganlovells.com" required autofocus class="login-input">
<label for="login-password" class="login-label">Passwort</label>
<input type="password" id="login-password" name="password" placeholder="Passwort" required class="login-input">
<button type="submit" class="login-button">Anmelden</button>
</form>
<form method="POST" action="/register" class="login-form" id="register-form"{{if ne .Mode "register"}} style="display:none"{{end}}>
<label for="reg-email" class="login-label">E-Mail</label>
<input type="email" id="reg-email" name="email" value="{{.Email}}" placeholder="name@hoganlovells.com" required class="login-input">
<label for="reg-password" class="login-label">Passwort</label>
<input type="password" id="reg-password" name="password" placeholder="Mind. 8 Zeichen" required minlength="8" class="login-input">
<label for="reg-confirm" class="login-label">Passwort bestätigen</label>
<input type="password" id="reg-confirm" name="confirm" placeholder="Passwort wiederholen" required minlength="8" class="login-input">
<button type="submit" class="login-button">Registrieren</button>
</form>
<p class="login-hint">Nur für @hoganlovells.com Adressen.</p>
</div>
</main>
<footer class="footer">
<div class="container">
<p>&copy; 2026 patholo &mdash; Internal use only. Hogan Lovells Patent Practice.</p>
</div>
</footer>
<script>
document.querySelectorAll('.login-tab').forEach(function(btn) {
btn.addEventListener('click', function() {
document.querySelectorAll('.login-tab').forEach(function(t) { t.classList.remove('active'); });
btn.classList.add('active');
document.getElementById('login-form').style.display = btn.dataset.tab === 'login' ? '' : 'none';
document.getElementById('register-form').style.display = btn.dataset.tab === 'register' ? '' : 'none';
document.querySelectorAll('.login-error, .login-success').forEach(function(el) { el.style.display = 'none'; });
});
});
</script>
{{end}}