181 lines
5.2 KiB
Go
181 lines
5.2 KiB
Go
package web
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.veillax.com/Veillax/nurse/internal/config"
|
|
"git.veillax.com/Veillax/nurse/internal/oidc"
|
|
)
|
|
|
|
const (
|
|
sessionCookie = "nurse_session"
|
|
flowCookie = "nurse_signin"
|
|
sessionTTL = 14 * 24 * time.Hour
|
|
flowTTL = 10 * time.Minute
|
|
)
|
|
|
|
// A cookie is "payload.signature": the payload is JSON, the signature is an HMAC with the secret from
|
|
// the settings file. Nothing is kept on the server, so a restart does not sign anyone out.
|
|
func seal(c config.Config, kind string, v any) string {
|
|
raw, _ := json.Marshal(v)
|
|
payload := base64.RawURLEncoding.EncodeToString(raw)
|
|
return payload + "." + mac(c, kind, payload)
|
|
}
|
|
|
|
func mac(c config.Config, kind, payload string) string {
|
|
m := hmac.New(sha256.New, []byte(c.Secret))
|
|
m.Write([]byte(kind + "|" + payload))
|
|
return hex.EncodeToString(m.Sum(nil))
|
|
}
|
|
|
|
func unseal(c config.Config, kind, value string, v any) bool {
|
|
payload, sig, ok := strings.Cut(value, ".")
|
|
if !ok || !hmac.Equal([]byte(sig), []byte(mac(c, kind, payload))) {
|
|
return false
|
|
}
|
|
raw, err := base64.RawURLEncoding.DecodeString(payload)
|
|
return err == nil && json.Unmarshal(raw, v) == nil
|
|
}
|
|
|
|
type sessionData struct {
|
|
Sub string `json:"s"`
|
|
Name string `json:"n"`
|
|
Email string `json:"e"`
|
|
User string `json:"u"`
|
|
Exp int64 `json:"x"`
|
|
}
|
|
|
|
type flowData struct {
|
|
State string `json:"s"`
|
|
Nonce string `json:"n"`
|
|
Verifier string `json:"v"`
|
|
Next string `json:"t"`
|
|
Exp int64 `json:"x"`
|
|
}
|
|
|
|
// session reads the sign-in cookie. The allowed list is checked on every request, so taking someone
|
|
// off it takes effect at once.
|
|
func (s *Server) session(c config.Config, r *http.Request) (sessionData, bool) {
|
|
var d sessionData
|
|
ck, err := r.Cookie(sessionCookie)
|
|
if err != nil || !unseal(c, "session", ck.Value, &d) || d.Sub == "" || time.Now().Unix() > d.Exp {
|
|
return sessionData{}, false
|
|
}
|
|
return d, allowed(c, d)
|
|
}
|
|
|
|
func allowed(c config.Config, d sessionData) bool {
|
|
if len(c.Allowed) == 0 {
|
|
return true
|
|
}
|
|
for _, a := range c.Allowed {
|
|
for _, who := range []string{d.Email, d.User, d.Sub} {
|
|
if who != "" && strings.EqualFold(strings.TrimSpace(a), who) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func setCookie(w http.ResponseWriter, c config.Config, name, value, path string, ttl time.Duration) {
|
|
ck := &http.Cookie{Name: name, Value: value, Path: path, HttpOnly: true, Secure: c.Secure(), SameSite: http.SameSiteLaxMode}
|
|
if value == "" {
|
|
ck.MaxAge = -1
|
|
} else {
|
|
ck.MaxAge = int(ttl.Seconds())
|
|
}
|
|
http.SetCookie(w, ck)
|
|
}
|
|
|
|
// Where to go after signing in: only a place inside Nurse, such as #/p/abc.
|
|
var nextRe = regexp.MustCompile(`^#/[A-Za-z0-9/_.~%-]{0,300}$`)
|
|
|
|
func (s *Server) authLogin(w http.ResponseWriter, r *http.Request) {
|
|
c := s.cfg.Get()
|
|
next := r.URL.Query().Get("next")
|
|
if !nextRe.MatchString(next) {
|
|
next = ""
|
|
}
|
|
f := flowData{State: oidc.Random(18), Nonce: oidc.Random(18), Verifier: oidc.Random(40), Next: next, Exp: time.Now().Add(flowTTL).Unix()}
|
|
to, err := s.oidc.AuthURL(r.Context(), f.State, f.Nonce, oidc.Challenge(f.Verifier))
|
|
if err != nil {
|
|
log.Printf("sign-in: %v", err)
|
|
http.Redirect(w, r, "/?signin=unavailable", http.StatusSeeOther)
|
|
return
|
|
}
|
|
setCookie(w, c, flowCookie, seal(c, "flow", f), "/auth/", flowTTL)
|
|
http.Redirect(w, r, to, http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) authCallback(w http.ResponseWriter, r *http.Request) {
|
|
c := s.cfg.Get()
|
|
back := func(why string) { http.Redirect(w, r, "/?signin="+why, http.StatusSeeOther) }
|
|
|
|
var f flowData
|
|
ck, err := r.Cookie(flowCookie)
|
|
setCookie(w, c, flowCookie, "", "/auth/", 0) // a sign-in attempt is used once
|
|
if err != nil || !unseal(c, "flow", ck.Value, &f) || time.Now().Unix() > f.Exp {
|
|
back("expired")
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
if e := q.Get("error"); e != "" {
|
|
log.Printf("sign-in: the provider answered %q: %s", e, q.Get("error_description"))
|
|
back("denied")
|
|
return
|
|
}
|
|
if subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(f.State)) != 1 || q.Get("code") == "" {
|
|
back("failed")
|
|
return
|
|
}
|
|
id, err := s.oidc.Exchange(r.Context(), q.Get("code"), f.Verifier, f.Nonce)
|
|
if err != nil {
|
|
log.Printf("sign-in: %v", err)
|
|
if errors.Is(err, oidc.ErrUnavailable) {
|
|
back("unavailable")
|
|
} else {
|
|
back("failed")
|
|
}
|
|
return
|
|
}
|
|
d := sessionData{Sub: id.Sub, Name: firstOf(id.Name, id.Username, id.Email, "You"), Email: id.Email, User: id.Username,
|
|
Exp: time.Now().Add(sessionTTL).Unix()}
|
|
if !allowed(c, d) {
|
|
log.Printf("sign-in: %s (%s) is not on the allowed list", d.Name, firstOf(d.Email, d.User, d.Sub))
|
|
back("denied")
|
|
return
|
|
}
|
|
setCookie(w, c, sessionCookie, seal(c, "session", d), "/", sessionTTL)
|
|
http.Redirect(w, r, "/"+f.Next, http.StatusSeeOther)
|
|
}
|
|
|
|
func firstOf(s ...string) string {
|
|
for _, x := range s {
|
|
if strings.TrimSpace(x) != "" {
|
|
return x
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *Server) logout(w http.ResponseWriter, r *http.Request, p principal) {
|
|
setCookie(w, s.cfg.Get(), sessionCookie, "", "/", 0)
|
|
writeJSON(w, map[string]bool{"ok": true})
|
|
}
|
|
|
|
func (s *Server) me(w http.ResponseWriter, r *http.Request, p principal) {
|
|
writeJSON(w, map[string]any{"name": p.Name, "email": p.Email, "version": s.version})
|
|
}
|