Add auth
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
package glance
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
mathrand "math/rand/v2"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const AUTH_SESSION_COOKIE_NAME = "session_token"
|
||||
const AUTH_RATE_LIMIT_WINDOW = 5 * time.Minute
|
||||
const AUTH_RATE_LIMIT_MAX_ATTEMPTS = 5
|
||||
|
||||
const AUTH_TOKEN_SECRET_LENGTH = 32
|
||||
const AUTH_USERNAME_HASH_LENGTH = 32
|
||||
const AUTH_SECRET_KEY_LENGTH = AUTH_TOKEN_SECRET_LENGTH + AUTH_USERNAME_HASH_LENGTH
|
||||
const AUTH_TIMESTAMP_LENGTH = 4 // uint32
|
||||
const AUTH_TOKEN_DATA_LENGTH = AUTH_USERNAME_HASH_LENGTH + AUTH_TIMESTAMP_LENGTH
|
||||
|
||||
// How long the token will be valid for
|
||||
const AUTH_TOKEN_VALID_PERIOD = 14 * 24 * time.Hour // 14 days
|
||||
// How long the token has left before it should be regenerated
|
||||
const AUTH_TOKEN_REGEN_BEFORE = 7 * 24 * time.Hour // 7 days
|
||||
|
||||
var loginPageTemplate = mustParseTemplate("login.html", "document.html", "footer.html")
|
||||
|
||||
type doWhenUnauthorized int
|
||||
|
||||
const (
|
||||
redirectToLogin doWhenUnauthorized = iota
|
||||
showUnauthorizedJSON
|
||||
)
|
||||
|
||||
type failedAuthAttempt struct {
|
||||
attempts int
|
||||
first time.Time
|
||||
}
|
||||
|
||||
func generateSessionToken(username string, secret []byte, now time.Time) (string, error) {
|
||||
if len(secret) != AUTH_SECRET_KEY_LENGTH {
|
||||
return "", fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||
}
|
||||
|
||||
usernameHash, err := computeUsernameHash(username, secret)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
data := make([]byte, AUTH_TOKEN_DATA_LENGTH)
|
||||
copy(data, usernameHash)
|
||||
expires := now.Add(AUTH_TOKEN_VALID_PERIOD).Unix()
|
||||
binary.LittleEndian.PutUint32(data[AUTH_USERNAME_HASH_LENGTH:], uint32(expires))
|
||||
|
||||
h := hmac.New(sha256.New, secret[0:AUTH_TOKEN_SECRET_LENGTH])
|
||||
h.Write(data)
|
||||
|
||||
signature := h.Sum(nil)
|
||||
encodedToken := base64.StdEncoding.EncodeToString(append(data, signature...))
|
||||
// encodedToken ends up being (hashed username + expiration timestamp + signature) encoded as base64
|
||||
|
||||
return encodedToken, nil
|
||||
}
|
||||
|
||||
func computeUsernameHash(username string, secret []byte) ([]byte, error) {
|
||||
if len(secret) != AUTH_SECRET_KEY_LENGTH {
|
||||
return nil, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||
}
|
||||
|
||||
h := hmac.New(sha256.New, secret[AUTH_TOKEN_SECRET_LENGTH:])
|
||||
h.Write([]byte(username))
|
||||
|
||||
return h.Sum(nil), nil
|
||||
}
|
||||
|
||||
func verifySessionToken(token string, secretBytes []byte, now time.Time) ([]byte, bool, error) {
|
||||
tokenBytes, err := base64.StdEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if len(tokenBytes) != AUTH_TOKEN_DATA_LENGTH+32 {
|
||||
return nil, false, fmt.Errorf("token length is invalid")
|
||||
}
|
||||
|
||||
if len(secretBytes) != AUTH_SECRET_KEY_LENGTH {
|
||||
return nil, false, fmt.Errorf("secret key length is not %d bytes", AUTH_SECRET_KEY_LENGTH)
|
||||
}
|
||||
|
||||
usernameHashBytes := tokenBytes[0:AUTH_USERNAME_HASH_LENGTH]
|
||||
timestampBytes := tokenBytes[AUTH_USERNAME_HASH_LENGTH : AUTH_USERNAME_HASH_LENGTH+AUTH_TIMESTAMP_LENGTH]
|
||||
providedSignatureBytes := tokenBytes[AUTH_TOKEN_DATA_LENGTH:]
|
||||
|
||||
h := hmac.New(sha256.New, secretBytes[0:32])
|
||||
h.Write(tokenBytes[0:AUTH_TOKEN_DATA_LENGTH])
|
||||
expectedSignatureBytes := h.Sum(nil)
|
||||
|
||||
if !hmac.Equal(expectedSignatureBytes, providedSignatureBytes) {
|
||||
return nil, false, fmt.Errorf("signature does not match")
|
||||
}
|
||||
|
||||
expiresTimestamp := int64(binary.LittleEndian.Uint32(timestampBytes))
|
||||
if now.Unix() > expiresTimestamp {
|
||||
return nil, false, fmt.Errorf("token has expired")
|
||||
}
|
||||
|
||||
return usernameHashBytes,
|
||||
// True if the token should be regenerated
|
||||
time.Unix(expiresTimestamp, 0).Add(-AUTH_TOKEN_REGEN_BEFORE).Before(now),
|
||||
nil
|
||||
}
|
||||
|
||||
func makeAuthSecretKey(length int) (string, error) {
|
||||
key := make([]byte, length)
|
||||
_, err := rand.Read(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(key), nil
|
||||
}
|
||||
|
||||
func (a *application) handleAuthenticationAttempt(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
waitOnFailure := 1*time.Second - time.Duration(mathrand.IntN(500))*time.Millisecond
|
||||
|
||||
ip := a.addressOfRequest(r)
|
||||
|
||||
a.authAttemptsMu.Lock()
|
||||
exceededRateLimit, retryAfter := func() (bool, int) {
|
||||
attempt, exists := a.failedAuthAttempts[ip]
|
||||
if !exists {
|
||||
a.failedAuthAttempts[ip] = &failedAuthAttempt{
|
||||
attempts: 1,
|
||||
first: time.Now(),
|
||||
}
|
||||
|
||||
return false, 0
|
||||
}
|
||||
|
||||
elapsed := time.Since(attempt.first)
|
||||
if elapsed < AUTH_RATE_LIMIT_WINDOW && attempt.attempts >= AUTH_RATE_LIMIT_MAX_ATTEMPTS {
|
||||
return true, max(1, int(AUTH_RATE_LIMIT_WINDOW.Seconds()-elapsed.Seconds()))
|
||||
}
|
||||
|
||||
attempt.attempts++
|
||||
return false, 0
|
||||
}()
|
||||
|
||||
if exceededRateLimit {
|
||||
a.authAttemptsMu.Unlock()
|
||||
time.Sleep(waitOnFailure)
|
||||
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
} else {
|
||||
// Clean up old failed attempts
|
||||
for ipOfAttempt := range a.failedAuthAttempts {
|
||||
if time.Since(a.failedAuthAttempts[ipOfAttempt].first) > AUTH_RATE_LIMIT_WINDOW {
|
||||
delete(a.failedAuthAttempts, ipOfAttempt)
|
||||
}
|
||||
}
|
||||
a.authAttemptsMu.Unlock()
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var creds struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
err = json.Unmarshal(body, &creds)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
logAuthFailure := func() {
|
||||
log.Printf(
|
||||
"Failed login attempt for user '%s' from %s",
|
||||
creds.Username, ip,
|
||||
)
|
||||
}
|
||||
|
||||
if len(creds.Username) == 0 || len(creds.Password) == 0 {
|
||||
time.Sleep(waitOnFailure)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if len(creds.Username) > 50 || len(creds.Password) > 100 {
|
||||
logAuthFailure()
|
||||
time.Sleep(waitOnFailure)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
u, exists := a.Config.Auth.Users[creds.Username]
|
||||
if !exists {
|
||||
logAuthFailure()
|
||||
time.Sleep(waitOnFailure)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword(u.PasswordHash, []byte(creds.Password)); err != nil {
|
||||
logAuthFailure()
|
||||
time.Sleep(waitOnFailure)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := generateSessionToken(creds.Username, a.authSecretKey, time.Now())
|
||||
if err != nil {
|
||||
log.Printf("Could not compute session token during login attempt: %v", err)
|
||||
time.Sleep(waitOnFailure)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
a.setAuthSessionCookie(w, r, token, time.Now().Add(AUTH_TOKEN_VALID_PERIOD))
|
||||
|
||||
a.authAttemptsMu.Lock()
|
||||
delete(a.failedAuthAttempts, ip)
|
||||
a.authAttemptsMu.Unlock()
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (a *application) isAuthorized(w http.ResponseWriter, r *http.Request) bool {
|
||||
if !a.RequiresAuth {
|
||||
return true
|
||||
}
|
||||
|
||||
token, err := r.Cookie(AUTH_SESSION_COOKIE_NAME)
|
||||
if err != nil || token.Value == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
usernameHash, shouldRegenerate, err := verifySessionToken(token.Value, a.authSecretKey, time.Now())
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
username, exists := a.usernameHashToUsername[string(usernameHash)]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
_, exists = a.Config.Auth.Users[username]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
if shouldRegenerate {
|
||||
newToken, err := generateSessionToken(username, a.authSecretKey, time.Now())
|
||||
if err != nil {
|
||||
log.Printf("Could not compute session token during regeneration: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
a.setAuthSessionCookie(w, r, newToken, time.Now().Add(AUTH_TOKEN_VALID_PERIOD))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Handles sending the appropriate response for an unauthorized request and returns true if the request was unauthorized
|
||||
func (a *application) handleUnauthorizedResponse(w http.ResponseWriter, r *http.Request, fallback doWhenUnauthorized) bool {
|
||||
if a.isAuthorized(w, r) {
|
||||
return false
|
||||
}
|
||||
|
||||
switch fallback {
|
||||
case redirectToLogin:
|
||||
http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther)
|
||||
case showUnauthorizedJSON:
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error": "Unauthorized"}`))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Maybe this should be a POST request instead?
|
||||
func (a *application) handleLogoutRequest(w http.ResponseWriter, r *http.Request) {
|
||||
a.setAuthSessionCookie(w, r, "", time.Now().Add(-1*time.Hour))
|
||||
http.Redirect(w, r, a.Config.Server.BaseURL+"/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *application) setAuthSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: AUTH_SESSION_COOKIE_NAME,
|
||||
Value: token,
|
||||
Expires: expires,
|
||||
Secure: strings.ToLower(r.Header.Get("X-Forwarded-Proto")) == "https",
|
||||
Path: a.Config.Server.BaseURL + "/",
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
HttpOnly: true,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *application) handleLoginPageRequest(w http.ResponseWriter, r *http.Request) {
|
||||
if a.isAuthorized(w, r) {
|
||||
http.Redirect(w, r, a.Config.Server.BaseURL+"/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
data := &templateData{
|
||||
App: a,
|
||||
}
|
||||
a.populateTemplateRequestData(&data.Request, r)
|
||||
|
||||
var responseBytes bytes.Buffer
|
||||
err := loginPageTemplate.Execute(&responseBytes, data)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
w.Write(responseBytes.Bytes())
|
||||
}
|
||||
Reference in New Issue
Block a user