mirror of
https://codeberg.org/readeck/readeck.git
synced 2025-12-22 05:07:08 +00:00
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.
210 lines
4.9 KiB
Go
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")
|
|
}
|