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
This commit is contained in:
265
internal/auth/auth.go
Normal file
265
internal/auth/auth.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user