Files
readeck/internal/auth/signin/http.go
Olivier Meunier 822d78d57d TOTP authentication
This is only the first part. When a totp_secret exists for a user, the
authentication then asks for the code and carries on.

The totp lib can handle 6 or 8 letter codes and sha1, sha256 and sha512.
For maximum compatibility with Google Authenticator though, it sticks
to 6 character and sha1.
2025-12-09 07:23:13 +01:00

210 lines
4.9 KiB
Go

// SPDX-FileCopyrightText: © 2021 Olivier Meunier <olivier@neokraft.net>
//
// SPDX-License-Identifier: AGPL-3.0-only
// Package signin contains the routes for Readeck sign-in process.
package signin
import (
"net/http"
"net/url"
"strings"
"time"
"github.com/doug-martin/goqu/v9"
"github.com/go-chi/chi/v5"
"codeberg.org/readeck/readeck/configs"
"codeberg.org/readeck/readeck/internal/auth/users"
"codeberg.org/readeck/readeck/internal/server"
"codeberg.org/readeck/readeck/pkg/forms"
"codeberg.org/readeck/readeck/pkg/totp"
)
// SetupRoutes mounts the routes for the auth domain.
func SetupRoutes(s *server.Server) {
newAuthHandler(s)
}
type authHandler struct {
chi.Router
srv *server.Server
}
func newAuthHandler(s *server.Server) *authHandler {
// Non authenticated routes
r := chi.NewRouter()
r.Use(
server.Csrf,
server.WithSession(),
)
h := &authHandler{r, s}
s.AddRoute("/login", r)
r.Get("/", h.login)
r.Post("/", h.login)
r.Get("/mfa", h.mfa)
r.Post("/mfa", h.mfa)
// Recovery
r.With(server.WithPermission("email", "send")).Route("/recover", func(r chi.Router) {
r.Get("/", h.recover)
r.Post("/", h.recover)
r.Get("/{code}", h.recover)
r.Post("/{code}", h.recover)
})
// Authenticated routes
ar := server.AuthenticatedRouter(server.WithRedirectLogin)
s.AddRoute("/logout", ar)
ar.Post("/", h.logout)
return h
}
func (h *authHandler) redirTo(w http.ResponseWriter, r *http.Request, redir string) {
// Get redirection from a form "redirect" parameter
// Since it goes to Redirect(), it will be sanitized there
// and can only stay within the app.
if redir == "" || strings.HasPrefix(redir, "/login") {
redir = "/"
}
server.Redirect(w, r, redir)
}
func (h *authHandler) redirToMFA(w http.ResponseWriter, r *http.Request, redir string) {
v := url.Values{"r": {redir}}
server.Redirect(w, r, "/login/mfa?"+v.Encode())
}
func (h *authHandler) login(w http.ResponseWriter, r *http.Request) {
f := newLoginForm(server.Locale(r))
if r.Method == http.MethodGet {
// Set the redirect value from the query string
f.Get("redirect").Set(r.URL.Query().Get("r"))
// Do we have a session already?
if sess := server.GetSession(r); sess.Payload.User != 0 {
if sess.Payload.RequiresMFA {
h.redirToMFA(w, r, f.Get("redirect").String())
return
}
h.redirTo(w, r, f.Get("redirect").String())
return
}
}
if r.Method == http.MethodPost {
forms.Bind(f, r)
if f.IsValid() {
user := checkUser(f)
if user != nil {
// User is authenticated, let's carry on
sess := server.GetSession(r)
sess.Payload.User = user.ID
sess.Payload.Seed = user.Seed
sess.Payload.RequiresMFA = user.RequiresMFA()
sess.Save(w, r)
if sess.Payload.RequiresMFA {
h.redirToMFA(w, r, f.Get("redirect").String())
return
}
h.redirTo(w, r, f.Get("redirect").String())
return
}
// we must set the content type to avoid the
// error middleware interception.
w.Header().Set("content-type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
}
w.WriteHeader(http.StatusUnprocessableEntity)
}
server.RenderTemplate(w, r, http.StatusOK, "/auth/login", server.TC{
"Form": f,
})
}
func (h *authHandler) mfa(w http.ResponseWriter, r *http.Request) {
sess := server.GetSession(r)
if !sess.Payload.RequiresMFA {
server.Redirect(w, r, "/")
return
}
user := new(users.User)
found, err := users.Users.Query().
SelectAppend(goqu.C("totp_secret").Table("u")).
Where(goqu.C("id").Eq(sess.Payload.User)).
ScanStruct(user)
if err != nil {
server.Err(w, r, err)
return
}
if !found {
server.Status(w, r, 404)
return
}
f := forms.Must(
forms.WithTranslator(r.Context(), server.Locale(r)),
forms.NewTextField("code", forms.Required, forms.StrLen(6, 6)),
forms.NewTextField("redirect"),
)
if r.Method == http.MethodGet {
// Set the redirect value from the query string
f.Get("redirect").Set(r.URL.Query().Get("r"))
}
status := http.StatusOK
if r.Method == http.MethodPost {
forms.Bind(f, r)
if f.IsValid() {
code := new(totp.Code)
if err := configs.Keys.TOTPKey().DecodeJSON(user.TOTPSecret, code); err != nil {
server.Err(w, r, err)
return
}
ok, err := code.Verify(f.Get("code").String(), time.Now().UTC(), 1)
if err != nil {
server.Err(w, r, err)
return
}
if ok {
sess.Payload.RequiresMFA = false
sess.Save(w, r)
redir := f.Get("redirect").String()
if redir == "" || strings.HasPrefix(redir, "/login") {
redir = "/"
}
server.Redirect(w, r, redir)
return
}
f.Get("code").AddErrors(forms.Gettext("Invalid code"))
}
status = http.StatusUnprocessableEntity
}
server.RenderTemplate(w, r, status, "/auth/totp", server.TC{
"Form": f,
})
}
func (h *authHandler) logout(w http.ResponseWriter, r *http.Request) {
// Clear session
sess := server.GetSession(r)
sess.Clear(w, r)
server.Redirect(w, r, "/login")
}