initial upload
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
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"},
|
||||
"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 && r.FormValue("csrf_token") != 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)
|
||||
}
|
||||
Reference in New Issue
Block a user