Gitfed
bastien-mrq/gitfed / cmd / gitfed-web / handlers_auth.go
package main

import (
	"bytes"
	"html/template"
	"log"
	"net/http"
	"strings"

	"git.neuromancer.ovh/bastien-mrq/gitfed/internal/i18n"
)

var loginTpl = newTpl("login", `
<div class="gf-auth-center">
  <div class="gf-auth-box">
    <div class="gf-auth-brand">{{.BrandMark}} Gitfed</div>
    <p class="gf-auth-tagline">{{t .Lang "landing.kicker"}}</p>
    {{.Flash}}
    <form class="card" method="post" action="/login">
      <input type="hidden" name="next" value="{{.Next}}">
      <label>{{t .Lang "auth.username"}}</label>
      <input name="username" required autofocus>
      <label>{{t .Lang "auth.password"}}</label>
      <input name="password" type="password" required>
      <button type="submit">{{t .Lang "auth.login"}}</button>
    </form>
    <p class="muted gf-auth-note">{{t .Lang "auth.note"}}</p>
  </div>
</div>
`)

func (s *server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
	if _, ok := s.currentSession(r); ok {
		http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
		return
	}
	lang := s.lang(r)
	next := sanitizeNext(r.URL.Query().Get("next"))
	var buf bytes.Buffer
	_ = loginTpl.Execute(&buf, struct {
		Next, Lang       string
		Flash, BrandMark template.HTML
	}{next, string(lang), flash(r), template.HTML(brandMark)})
	s.render(w, r, i18n.T(lang, "auth.login"), "login", template.HTML(buf.String()))
}

func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) {
	username := r.FormValue("username")
	password := r.FormValue("password")
	lang := s.lang(r)
	next := sanitizeNext(r.FormValue("next"))
	loginURL := "/login?next=" + template.URLQueryEscaper(next)

	// Refuse before touching bcrypt once either the source IP or the target
	// account has accumulated too many recent failures.
	ip := clientIP(r)
	if !s.loginByIP.allowed(ip) || !s.loginByUser.allowed(username) {
		redirectWithMsg(w, r, loginURL, i18n.T(lang, "auth.rate_limited"), true)
		return
	}

	isAdmin, ok, err := s.ops.VerifyPassword(username, password)
	if err != nil {
		log.Printf("gitfed-web: verify password for %q: %v", username, err)
		redirectWithMsg(w, r, loginURL, i18n.T(lang, "auth.error"), true)
		return
	}
	if !ok {
		s.loginByIP.record(ip)
		s.loginByUser.record(username)
		redirectWithMsg(w, r, loginURL, i18n.T(lang, "auth.invalid_login"), true)
		return
	}

	// Proven identity — clear the counters so a later typo isn't penalised
	// against an earlier attacker's tally.
	s.loginByIP.reset(ip)
	s.loginByUser.reset(username)

	principal := username + "@" + s.domain
	token, err := s.ops.CreateSession(principal, username, isAdmin)
	if err != nil {
		log.Printf("gitfed-web: create session for %q: %v", username, err)
		redirectWithMsg(w, r, loginURL, i18n.T(lang, "auth.error"), true)
		return
	}
	setSessionCookie(w, token)
	http.Redirect(w, r, next, http.StatusSeeOther)
}

func (s *server) handleLogout(w http.ResponseWriter, r *http.Request) {
	if c, err := r.Cookie(sessionCookieName); err == nil {
		_ = s.ops.DeleteSession(c.Value)
	}
	clearSessionCookie(w)
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

// sanitizeNext keeps "next" redirects local-only — otherwise a crafted
// login link ("?next=https://evil.example") could bounce a freshly
// authenticated user off to an attacker's site.
func sanitizeNext(next string) string {
	if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
		return "/dashboard"
	}
	return next
}