server: implement authentication
This commit is contained in:
+201
-1
@@ -1,6 +1,206 @@
|
||||
package api
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var jwtSecret = []byte(os.Getenv("JWT_SECRET"))
|
||||
|
||||
func hashToken(token string) string {
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func Login(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseMultipartForm(64 << 10)
|
||||
if err != nil {
|
||||
http.Error(w, "Unable to parse form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
if username == "" || password == "" {
|
||||
http.Error(w, "Username and password cannot be empty", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := dbGetUserByName(username)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid username or password", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := validatePassword(user.Password, password); err != nil {
|
||||
http.Error(w, "Invalid username or password", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
sessionToken := CreateSession(user.ID)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_token",
|
||||
Value: sessionToken,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: false,
|
||||
})
|
||||
|
||||
slog.Info("auth: login successful", "userid", user.ID, "username", user.Name)
|
||||
w.Write([]byte("Login successful"))
|
||||
}
|
||||
|
||||
func Logout(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("session_token")
|
||||
if err != nil {
|
||||
http.Error(w, "No session cookie found. You are already logged out", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sessionToken := cookie.Value
|
||||
userID, valid := ValidateSession(sessionToken)
|
||||
if !valid {
|
||||
http.Error(w, "Session cookie could not be validated. You are already logged out", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := dbGetUser(userID.String())
|
||||
if err != nil {
|
||||
http.Error(w, "Session cookie validated but user could not be found", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
DeleteSession(sessionToken)
|
||||
|
||||
cookie.Expires = time.Now()
|
||||
http.SetCookie(w, cookie)
|
||||
|
||||
slog.Debug("auth: logout successful", "user ID", user.ID, "username", user.Name)
|
||||
w.Write([]byte(fmt.Sprintf("%v has been logged out", user.Name)))
|
||||
}
|
||||
|
||||
func ValidateSession(sessionToken string) (uuid.UUID, bool) {
|
||||
token, err := jwt.Parse(sessionToken, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return jwtSecret, nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
slog.Debug("auth: session token invalid, rejecting")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
slog.Debug("auth: could not map claims from JWT")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
|
||||
userIDStr, ok := claims["userid"].(string)
|
||||
if !ok {
|
||||
slog.Debug("auth: userID claim is not a string")
|
||||
return uuid.Nil, false
|
||||
}
|
||||
|
||||
userID, err := uuid.Parse(userIDStr)
|
||||
if err != nil {
|
||||
slog.Debug("auth: failed to parse userID as uuid", "error", err)
|
||||
return uuid.Nil, false
|
||||
}
|
||||
|
||||
hashedToken := hashToken(sessionToken)
|
||||
|
||||
session, err := dbGetSession(hashedToken)
|
||||
if err != nil {
|
||||
slog.Debug("auth: failed to retrieve session from db", "error", err)
|
||||
return uuid.Nil, false
|
||||
}
|
||||
|
||||
slog.Debug("auth: session validated", "userID", session.UserID)
|
||||
return userID, true
|
||||
}
|
||||
|
||||
func DeleteSession(sessionToken string) bool {
|
||||
hashedToken := hashToken(sessionToken)
|
||||
|
||||
err := dbDeleteSession(hashedToken)
|
||||
if err != nil {
|
||||
slog.Error("auth: failed to delete session", "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
slog.Debug("auth: session deleted", "token", hashedToken)
|
||||
return true
|
||||
}
|
||||
|
||||
type contextKey string
|
||||
|
||||
const userIDKey contextKey = "userID"
|
||||
|
||||
func SessionAuthMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("session_token")
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
sessionToken := cookie.Value
|
||||
userID, valid := ValidateSession(sessionToken)
|
||||
if !valid {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Add username to request context
|
||||
ctx := context.WithValue(r.Context(), userIDKey, userID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
Token string
|
||||
UserID uuid.UUID
|
||||
Expiry time.Time
|
||||
}
|
||||
|
||||
func CreateSession(userID uuid.UUID) string {
|
||||
expiry := time.Now().Add(7 * 24 * time.Hour)
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"userid": userID.String(),
|
||||
"exp": expiry.Unix(), // 7 day token
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString(jwtSecret)
|
||||
if err != nil {
|
||||
slog.Error("auth: failed to create JWT", "error", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
hashedToken := hashToken(tokenString)
|
||||
session := Session{
|
||||
Token: hashedToken,
|
||||
UserID: userID,
|
||||
Expiry: expiry,
|
||||
}
|
||||
dbAddSession(&session)
|
||||
|
||||
slog.Debug("auth: new session created", "userid", session.UserID)
|
||||
return tokenString
|
||||
}
|
||||
|
||||
func hashPassword(password string) (string, error) {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password),
|
||||
|
||||
Reference in New Issue
Block a user