Files
TA-Deployment-Broker/internal/app/auth.go
T
2026-07-25 14:51:48 -05:00

288 lines
7.6 KiB
Go

package app
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
const (
sessionCookieName = "tapm_session"
oauthCookieName = "tapm_oauth_state"
)
type giteaTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
}
type giteaUser struct {
Login string `json:"login"`
FullName string `json:"full_name"`
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
state, err := randomToken(24)
if err != nil {
http.Error(w, "unable to start sign-in", http.StatusInternalServerError)
return
}
signedState := state + "." + signValue(s.cfg.CookieSecret, state)
http.SetCookie(w, &http.Cookie{
Name: oauthCookieName,
Value: signedState,
Path: "/auth",
MaxAge: 600,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
redirectURI := strings.TrimRight(s.cfg.PublicURL.String(), "/") + "/auth/callback"
query := url.Values{
"client_id": {s.cfg.GiteaClientID},
"redirect_uri": {redirectURI},
"response_type": {"code"},
"scope": {"read:user"},
"state": {state},
}
http.Redirect(
w,
r,
joinURL(s.cfg.GiteaURL, "/login/oauth/authorize")+"?"+query.Encode(),
http.StatusFound,
)
}
func (s *Server) handleCallback(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(oauthCookieName)
if err != nil {
http.Error(w, "sign-in state is missing", http.StatusBadRequest)
return
}
parts := strings.Split(cookie.Value, ".")
if len(parts) != 2 ||
!verifySignature(s.cfg.CookieSecret, parts[0], parts[1]) ||
r.URL.Query().Get("state") != parts[0] {
http.Error(w, "sign-in state is invalid", http.StatusBadRequest)
return
}
http.SetCookie(w, &http.Cookie{
Name: oauthCookieName,
Value: "",
Path: "/auth",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "authorization code is missing", http.StatusBadRequest)
return
}
user, err := s.exchangeOAuthCode(r.Context(), code)
if err != nil {
http.Error(w, "Gitea sign-in failed", http.StatusBadGateway)
return
}
if _, allowed := s.cfg.AllowedGiteaUsers[strings.ToLower(user.Login)]; !allowed {
_ = s.audit(r.Context(), "login_denied", user.Login, nil, "", "", s.clientIP(r), "user is not allowed")
http.Error(w, "this Gitea user is not authorized", http.StatusForbidden)
return
}
sessionToken, err := randomToken(32)
if err != nil {
http.Error(w, "unable to create session", http.StatusInternalServerError)
return
}
csrfToken, err := randomToken(24)
if err != nil {
http.Error(w, "unable to create session", http.StatusInternalServerError)
return
}
tokenHash := hashValue(sessionToken)
displayName := strings.TrimSpace(user.FullName)
if displayName == "" {
displayName = user.Login
}
expiresAt := time.Now().UTC().Add(12 * time.Hour)
_, err = s.db.ExecContext(
r.Context(),
`INSERT INTO technician_sessions
(token_hash, csrf_token, gitea_login, display_name, expires_at)
VALUES (?, ?, ?, ?, ?)`,
tokenHash[:], csrfToken, user.Login, displayName, expiresAt,
)
if err != nil {
http.Error(w, "unable to save session", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: sessionToken,
Path: "/",
Expires: expiresAt,
MaxAge: int(time.Until(expiresAt).Seconds()),
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
_ = s.audit(r.Context(), "login_succeeded", user.Login, nil, "", "", s.clientIP(r), "")
http.Redirect(w, r, "/portal", http.StatusSeeOther)
}
func (s *Server) exchangeOAuthCode(ctx context.Context, code string) (giteaUser, error) {
var user giteaUser
redirectURI := strings.TrimRight(s.cfg.PublicURL.String(), "/") + "/auth/callback"
form := url.Values{
"client_id": {s.cfg.GiteaClientID},
"client_secret": {s.cfg.GiteaClientSecret},
"code": {code},
"grant_type": {"authorization_code"},
"redirect_uri": {redirectURI},
}
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
joinURL(s.cfg.GiteaURL, "/login/oauth/access_token"),
strings.NewReader(form.Encode()),
)
if err != nil {
return user, err
}
request.Header.Set("Accept", "application/json")
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response, err := s.client.Do(request)
if err != nil {
return user, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return user, fmt.Errorf("token endpoint returned %s", response.Status)
}
var token giteaTokenResponse
if err := json.NewDecoder(response.Body).Decode(&token); err != nil {
return user, err
}
if token.AccessToken == "" {
return user, fmt.Errorf("token endpoint returned an empty token")
}
request, err = http.NewRequestWithContext(
ctx,
http.MethodGet,
joinURL(s.cfg.GiteaURL, "/api/v1/user"),
nil,
)
if err != nil {
return user, err
}
request.Header.Set("Authorization", "token "+token.AccessToken)
response, err = s.client.Do(request)
if err != nil {
return user, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return user, fmt.Errorf("user endpoint returned %s", response.Status)
}
if err := json.NewDecoder(response.Body).Decode(&user); err != nil {
return user, err
}
if user.Login == "" {
return user, fmt.Errorf("user endpoint returned an empty login")
}
return user, nil
}
func (s *Server) currentTechnician(r *http.Request) (*technician, error) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
return nil, err
}
tokenHash := hashValue(cookie.Value)
var tech technician
err = s.db.QueryRowContext(
r.Context(),
`SELECT gitea_login, display_name, csrf_token
FROM technician_sessions
WHERE token_hash = ? AND expires_at > UTC_TIMESTAMP(6)`,
tokenHash[:],
).Scan(&tech.Login, &tech.DisplayName, &tech.CSRFToken)
if err != nil {
return nil, err
}
_, _ = s.db.ExecContext(
r.Context(),
`UPDATE technician_sessions SET last_seen_at = UTC_TIMESTAMP(6)
WHERE token_hash = ?`,
tokenHash[:],
)
return &tech, nil
}
func (s *Server) requireTechnician(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tech, err := s.currentTechnician(r)
if err != nil {
if !errorsIsNoRowsOrCookie(err) {
http.Error(w, "unable to validate session", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if r.Method != http.MethodGet {
csrfToken := r.Header.Get("X-CSRF-Token")
if csrfToken == "" {
csrfToken = r.FormValue("csrf_token")
}
if csrfToken != tech.CSRFToken {
http.Error(w, "invalid request token", http.StatusForbidden)
return
}
}
next(w, r)
}
}
func errorsIsNoRowsOrCookie(err error) bool {
return err == http.ErrNoCookie || err == sql.ErrNoRows
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
tech, err := s.currentTechnician(r)
if err != nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if r.FormValue("csrf_token") != tech.CSRFToken {
http.Error(w, "invalid request token", http.StatusForbidden)
return
}
if cookie, err := r.Cookie(sessionCookieName); err == nil {
tokenHash := hashValue(cookie.Value)
_, _ = s.db.ExecContext(r.Context(), `DELETE FROM technician_sessions WHERE token_hash = ?`, tokenHash[:])
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}