288 lines
7.6 KiB
Go
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 > CURRENT_TIMESTAMP`,
|
|
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 = CURRENT_TIMESTAMP
|
|
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)
|
|
}
|