package api import ( "context" "crypto/sha256" "encoding/hex" "fmt" "log/slog" "net/http" "os" "time" "github.com/golang-jwt/jwt/v5" "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 } err = validatePassword(user.Password, password) if 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", "userID", user.ID, "userName", user.Name) w.Write([]byte(fmt.Sprintf("%v has been logged out", user.Name))) } 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 expiry "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 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 } if time.Now().After(session.Expiry) { slog.Debug("auth: session is expired (or otherwise invalid) in db") 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)) }) } func (u *UserPayload) Render(w http.ResponseWriter, r *http.Request) error { return nil } func hashPassword(password string) (string, error) { hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) return string(hashedPassword), err } func validatePassword(hashedPassword, password string) error { return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) }