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.
This commit is contained in:
Olivier Meunier
2025-11-23 14:52:32 +01:00
parent 3f5f50ed5d
commit 822d78d57d
15 changed files with 607 additions and 45 deletions

View File

@@ -7,12 +7,18 @@ 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.
@@ -37,6 +43,8 @@ func newAuthHandler(s *server.Server) *authHandler {
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) {
@@ -54,6 +62,21 @@ func newAuthHandler(s *server.Server) *authHandler {
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))
@@ -62,8 +85,12 @@ func (h *authHandler) login(w http.ResponseWriter, r *http.Request) {
f.Get("redirect").Set(r.URL.Query().Get("r"))
// Do we have a session already?
if server.GetSession(r).Payload.User != 0 {
server.Redirect(w, r, h.cleanRedir(f.Get("redirect").String()))
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
}
}
@@ -73,17 +100,21 @@ func (h *authHandler) login(w http.ResponseWriter, r *http.Request) {
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)
// Get redirection from a form "redirect" parameter
// Since it goes to Redirect(), it will be sanitized there
// and can only stay within the app.
server.Redirect(w, r, h.cleanRedir(f.Get("redirect").String()))
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
@@ -99,6 +130,76 @@ func (h *authHandler) login(w http.ResponseWriter, r *http.Request) {
})
}
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)
@@ -106,10 +207,3 @@ func (h *authHandler) logout(w http.ResponseWriter, r *http.Request) {
server.Redirect(w, r, "/login")
}
func (h *authHandler) cleanRedir(redir string) string {
if redir == "" || strings.HasPrefix(redir, "/login") {
redir = "/"
}
return redir
}