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
}