All checks were successful
Build & push Docker image / build (push) Successful in 1m44s
1336 lines
36 KiB
Go
1336 lines
36 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/cipher"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"crypto/tls"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"net/smtp"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"misaki/db"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
const (
|
|
sessionCookie = "session"
|
|
sessionTTL = 7 * 24 * time.Hour
|
|
bcryptCost = 12
|
|
)
|
|
|
|
var (
|
|
validUsernameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
|
reservedNames = map[string]bool{
|
|
"admin": true, "root": true, "www": true, "mail": true,
|
|
"postmaster": true, "webmaster": true, "misaki": true,
|
|
"abuse": true, "hostmaster": true, "noreply": true, "no-reply": true,
|
|
}
|
|
dummyHash []byte
|
|
)
|
|
|
|
func init() {
|
|
dummyHash, _ = bcrypt.GenerateFromPassword([]byte("timing-guard-dummy-hash-do-not-use"), bcryptCost)
|
|
}
|
|
|
|
type rateLimiter struct {
|
|
mu sync.Mutex
|
|
buckets map[string][]time.Time
|
|
limit int
|
|
window time.Duration
|
|
}
|
|
|
|
func newRateLimiter(limit int, window time.Duration) *rateLimiter {
|
|
rl := &rateLimiter{
|
|
buckets: make(map[string][]time.Time),
|
|
limit: limit,
|
|
window: window,
|
|
}
|
|
go func() {
|
|
for range time.Tick(5 * time.Minute) {
|
|
rl.evict()
|
|
}
|
|
}()
|
|
return rl
|
|
}
|
|
|
|
func (rl *rateLimiter) allow(key string) bool {
|
|
rl.mu.Lock()
|
|
defer rl.mu.Unlock()
|
|
now := time.Now()
|
|
cutoff := now.Add(-rl.window)
|
|
ts := rl.buckets[key]
|
|
n := 0
|
|
for _, t := range ts {
|
|
if t.After(cutoff) {
|
|
ts[n] = t
|
|
n++
|
|
}
|
|
}
|
|
ts = ts[:n]
|
|
if len(ts) >= rl.limit {
|
|
rl.buckets[key] = ts
|
|
return false
|
|
}
|
|
rl.buckets[key] = append(ts, now)
|
|
return true
|
|
}
|
|
|
|
func (rl *rateLimiter) blocked(key string) bool {
|
|
rl.mu.Lock()
|
|
defer rl.mu.Unlock()
|
|
cutoff := time.Now().Add(-rl.window)
|
|
ts := rl.buckets[key]
|
|
n := 0
|
|
for _, t := range ts {
|
|
if t.After(cutoff) {
|
|
ts[n] = t
|
|
n++
|
|
}
|
|
}
|
|
rl.buckets[key] = ts[:n]
|
|
return n >= rl.limit
|
|
}
|
|
|
|
// record logs one (failed) attempt against key.
|
|
func (rl *rateLimiter) record(key string) {
|
|
rl.mu.Lock()
|
|
defer rl.mu.Unlock()
|
|
rl.buckets[key] = append(rl.buckets[key], time.Now())
|
|
}
|
|
|
|
func (rl *rateLimiter) evict() {
|
|
rl.mu.Lock()
|
|
defer rl.mu.Unlock()
|
|
cutoff := time.Now().Add(-rl.window)
|
|
for key, ts := range rl.buckets {
|
|
n := 0
|
|
for _, t := range ts {
|
|
if t.After(cutoff) {
|
|
ts[n] = t
|
|
n++
|
|
}
|
|
}
|
|
if n == 0 {
|
|
delete(rl.buckets, key)
|
|
} else {
|
|
rl.buckets[key] = ts[:n]
|
|
}
|
|
}
|
|
}
|
|
|
|
type SMTPConfig struct {
|
|
Host string
|
|
User string
|
|
Pass string
|
|
From string
|
|
To string
|
|
TLSName string // overrides the hostname used for TLS cert verification
|
|
}
|
|
|
|
func (c SMTPConfig) send(to, subject, body string) error {
|
|
if c.Host == "" {
|
|
return nil
|
|
}
|
|
host := strings.Split(c.Host, ":")[0]
|
|
tlsName := c.TLSName
|
|
if tlsName == "" {
|
|
tlsName = host
|
|
}
|
|
client, err := smtp.Dial(c.Host)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer client.Close()
|
|
if ok, _ := client.Extension("STARTTLS"); ok {
|
|
if err := client.StartTLS(&tls.Config{ServerName: tlsName}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := client.Auth(smtp.PlainAuth("", c.User, c.Pass, host)); err != nil {
|
|
return err
|
|
}
|
|
if err := client.Mail(c.From); err != nil {
|
|
return err
|
|
}
|
|
if err := client.Rcpt(to); err != nil {
|
|
return err
|
|
}
|
|
w, err := client.Data()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\n\r\n%s", c.From, to, subject, body)
|
|
if _, err := w.Write([]byte(msg)); err != nil {
|
|
return err
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
return err
|
|
}
|
|
return client.Quit()
|
|
}
|
|
|
|
func (c SMTPConfig) notify(subject, body string) error {
|
|
if c.To == "" {
|
|
return nil
|
|
}
|
|
return c.send(c.To, subject, body)
|
|
}
|
|
|
|
type ServiceURLs struct {
|
|
GancioURL string
|
|
AkkomaURL string
|
|
ProsodyURL string
|
|
ForgejoURL string
|
|
MumbleHost string
|
|
MumblePass string
|
|
TransferHost string
|
|
TransferUser string
|
|
TransferPass string
|
|
}
|
|
|
|
type Config struct {
|
|
SecureCookies bool
|
|
InfoURLs map[string]string
|
|
CookieURLs map[string]string
|
|
InternalSecret string
|
|
AuthSecret string
|
|
BaseDomain string
|
|
DefaultNote string
|
|
OIDC *OIDCConfig
|
|
TOTPKeyFile string
|
|
PublicURL string
|
|
}
|
|
|
|
var svcType = map[string]string{
|
|
"mail": "form",
|
|
"werc": "form",
|
|
"ergo": "request",
|
|
"gancio": "link",
|
|
"akkoma": "oidc",
|
|
"pixelfed": "oidc",
|
|
"prosody": "request",
|
|
"forgejo": "link",
|
|
"cerca": "request",
|
|
"streaming": "request",
|
|
"radio": "request",
|
|
"files": "request",
|
|
"mumble": "display",
|
|
"transfer": "cookie",
|
|
"notes": "cookie",
|
|
"paste": "cookie",
|
|
"rss": "cookie",
|
|
}
|
|
|
|
// adminDataHints marks services whose grant carries admin-set data, with the field's placeholder.
|
|
var adminDataHints = map[string]string{
|
|
"cerca": "registration link",
|
|
"streaming": "mediamtx stream key",
|
|
"radio": "stations, e.g. fmradio,live (* = all)",
|
|
"files": "mounts, e.g. share,fmradio (* = admin/all)",
|
|
}
|
|
|
|
type serviceCard struct {
|
|
Service string
|
|
Label string
|
|
Type string
|
|
Applied bool
|
|
Granted bool
|
|
Denied bool
|
|
DenyReason string
|
|
Data string
|
|
Desc template.HTML
|
|
InfoURL string
|
|
URL string
|
|
NeedsEmail bool
|
|
}
|
|
|
|
type Server struct {
|
|
db *db.DB
|
|
mux *http.ServeMux
|
|
tmpl map[string]*template.Template
|
|
smtp SMTPConfig
|
|
svcURLs ServiceURLs
|
|
svcDescs map[string]template.HTML
|
|
svcInfoURLs map[string]string
|
|
svcCookieURLs map[string]string
|
|
secureCookies bool
|
|
internalSecret string
|
|
authSecret []byte
|
|
baseDomain string
|
|
defaultNote string
|
|
loginLimiter *rateLimiter
|
|
applyLimiter *rateLimiter
|
|
streamLimiter *rateLimiter
|
|
authLimiter *rateLimiter
|
|
tokenLimiter *rateLimiter
|
|
codeLimiter *rateLimiter
|
|
resetLimiter *rateLimiter
|
|
oidc *oidcProvider
|
|
totp cipher.AEAD
|
|
publicURL string
|
|
pendingMu sync.Mutex
|
|
pending map[string]*pendingLogin
|
|
}
|
|
|
|
func New(database *db.DB, svcURLs ServiceURLs, svcDescs map[string]template.HTML, smtpCfg SMTPConfig, cfg Config) *Server {
|
|
defaultNote := cfg.DefaultNote
|
|
if defaultNote == "" {
|
|
defaultNote = "I love misaki!"
|
|
}
|
|
s := &Server{
|
|
db: database,
|
|
smtp: smtpCfg,
|
|
svcURLs: svcURLs,
|
|
svcDescs: svcDescs,
|
|
svcInfoURLs: cfg.InfoURLs,
|
|
svcCookieURLs: cfg.CookieURLs,
|
|
secureCookies: cfg.SecureCookies,
|
|
internalSecret: cfg.InternalSecret,
|
|
authSecret: []byte(cfg.AuthSecret),
|
|
baseDomain: cfg.BaseDomain,
|
|
defaultNote: defaultNote,
|
|
loginLimiter: newRateLimiter(10, 15*time.Minute),
|
|
applyLimiter: newRateLimiter(3, time.Hour),
|
|
streamLimiter: newRateLimiter(10, 15*time.Minute),
|
|
authLimiter: newRateLimiter(10, 15*time.Minute),
|
|
tokenLimiter: newRateLimiter(20, 15*time.Minute),
|
|
codeLimiter: newRateLimiter(5, time.Hour),
|
|
resetLimiter: newRateLimiter(5, time.Hour),
|
|
publicURL: strings.TrimSuffix(cfg.PublicURL, "/"),
|
|
pending: map[string]*pendingLogin{},
|
|
}
|
|
if cfg.TOTPKeyFile != "" {
|
|
aead, err := loadTOTPKey(cfg.TOTPKeyFile)
|
|
if err != nil {
|
|
log.Fatalf("totp: %v", err)
|
|
}
|
|
s.totp = aead
|
|
}
|
|
oidc, err := newOIDCProvider(cfg.OIDC)
|
|
if err != nil {
|
|
log.Fatalf("oidc: %v", err)
|
|
}
|
|
s.oidc = oidc
|
|
pages := []string{"index.html", "apply.html", "applied.html", "login.html", "logout.html", "dashboard.html", "admin.html", "help.html", "404.html", "500.html", "code.html", "reset.html", "security.html"}
|
|
s.tmpl = make(map[string]*template.Template, len(pages))
|
|
for _, p := range pages {
|
|
s.tmpl[p] = template.Must(template.ParseFiles("tmpl/base.html", "tmpl/"+p))
|
|
}
|
|
s.routes()
|
|
return s
|
|
}
|
|
|
|
func (s *Server) routes() {
|
|
mux := http.NewServeMux()
|
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
|
mux.HandleFunc("GET /", s.handleIndex)
|
|
mux.HandleFunc("GET /help", s.requireUser(s.handleHelp))
|
|
mux.HandleFunc("GET /apply", s.handleApplyGet)
|
|
mux.HandleFunc("POST /apply", s.handleApplyPost)
|
|
mux.HandleFunc("GET /applied", s.handleApplied)
|
|
mux.HandleFunc("GET /login", s.handleLoginGet)
|
|
mux.HandleFunc("POST /login", s.handleLoginPost)
|
|
mux.HandleFunc("GET /login/code", s.handleCodeGet)
|
|
mux.HandleFunc("POST /login/code", s.handleCodePost)
|
|
mux.HandleFunc("GET /reset", s.handleResetGet)
|
|
mux.HandleFunc("POST /reset", s.handleResetPost)
|
|
mux.HandleFunc("POST /reset/confirm", s.handleResetConfirm)
|
|
mux.HandleFunc("POST /dashboard/password", s.requireUser(s.handlePasswordChange))
|
|
mux.HandleFunc("POST /dashboard/2fa/setup", s.requireUser(s.handleTOTPSetup))
|
|
mux.HandleFunc("POST /dashboard/2fa/enable", s.requireUser(s.handleTOTPEnable))
|
|
mux.HandleFunc("POST /dashboard/2fa/disable", s.requireUser(s.handleTOTPDisable))
|
|
mux.HandleFunc("GET /logout", s.handleLogoutGet)
|
|
mux.HandleFunc("POST /logout", s.handleLogout)
|
|
mux.HandleFunc("GET /dashboard", s.requireUser(s.handleDashboard))
|
|
mux.HandleFunc("POST /dashboard/apply", s.requireUser(s.handleApplyService))
|
|
mux.HandleFunc("POST /dashboard/request-reset", s.requireUser(s.handleRequestReset))
|
|
mux.HandleFunc("POST /dashboard/request-namechange", s.requireUser(s.handleRequestNameChange))
|
|
mux.HandleFunc("POST /dashboard/username", s.requireUser(s.handleRename))
|
|
mux.HandleFunc("POST /dashboard/note", s.requireUser(s.handleUpdateNote))
|
|
mux.HandleFunc("POST /dashboard/email", s.requireUser(s.handleUpdateEmail))
|
|
mux.HandleFunc("GET /admin", s.requireAdmin(s.handleAdmin))
|
|
mux.HandleFunc("POST /admin/action", s.requireAdmin(s.handleAdminAction))
|
|
mux.HandleFunc("POST /admin/service", s.requireAdmin(s.handleAdminToggleService))
|
|
mux.HandleFunc("POST /admin/user-service", s.requireAdmin(s.handleAdminUserService))
|
|
if s.internalSecret != "" {
|
|
mux.HandleFunc("POST /internal/auth", s.handleInternalAuth)
|
|
mux.HandleFunc("POST /internal/stream-auth", s.handleInternalStreamAuth)
|
|
mux.HandleFunc("GET /internal/users", s.handleInternalUsers)
|
|
}
|
|
mux.HandleFunc("GET /internal/session-check", s.handleInternalSessionCheck)
|
|
s.oidcRoutes(mux)
|
|
s.mux = mux
|
|
}
|
|
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) }
|
|
|
|
// ListenAndServe serves until ctx is cancelled, then drains in-flight requests.
|
|
func (s *Server) ListenAndServe(ctx context.Context, addr string) error {
|
|
hs := &http.Server{Addr: addr, Handler: s.mux, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 2 * time.Minute}
|
|
errc := make(chan error, 1)
|
|
go func() { errc <- hs.ListenAndServe() }()
|
|
select {
|
|
case err := <-errc:
|
|
return err
|
|
case <-ctx.Done():
|
|
}
|
|
sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
return hs.Shutdown(sctx)
|
|
}
|
|
|
|
func (s *Server) render(w http.ResponseWriter, name string, data any) {
|
|
t, ok := s.tmpl[name]
|
|
if !ok {
|
|
log.Printf("unknown template: %s", name)
|
|
s.internalError(w)
|
|
return
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := t.ExecuteTemplate(&buf, "base.html", data); err != nil {
|
|
log.Printf("template %s: %v", name, err)
|
|
s.internalError(w)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
buf.WriteTo(w)
|
|
}
|
|
|
|
func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
|
|
t, ok := s.tmpl["404.html"]
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := t.ExecuteTemplate(&buf, "base.html", nil); err != nil {
|
|
log.Printf("404 template: %v", err)
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
buf.WriteTo(w)
|
|
}
|
|
|
|
func (s *Server) internalError(w http.ResponseWriter) {
|
|
t, ok := s.tmpl["500.html"]
|
|
if !ok {
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := t.ExecuteTemplate(&buf, "base.html", nil); err != nil {
|
|
log.Printf("500 template: %v", err)
|
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
buf.WriteTo(w)
|
|
}
|
|
|
|
func (s *Server) isAllowedRedirect(u string) bool {
|
|
if u == "" || s.baseDomain == "" {
|
|
return false
|
|
}
|
|
parsed, err := url.Parse(u)
|
|
if err != nil || parsed.Scheme != "https" {
|
|
return false
|
|
}
|
|
host := parsed.Hostname()
|
|
return host == s.baseDomain || strings.HasSuffix(host, "."+s.baseDomain)
|
|
}
|
|
|
|
func randomToken() (string, error) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|
|
|
|
func clientIP(r *http.Request) string {
|
|
if ip := r.Header.Get("X-Real-IP"); ip != "" {
|
|
return ip
|
|
}
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
if idx := strings.Index(xff, ","); idx != -1 {
|
|
return strings.TrimSpace(xff[:idx])
|
|
}
|
|
return strings.TrimSpace(xff)
|
|
}
|
|
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
|
return host
|
|
}
|
|
return r.RemoteAddr
|
|
}
|
|
|
|
func validEmail(e string) bool {
|
|
at := strings.LastIndex(e, "@")
|
|
if at < 1 || at == len(e)-1 {
|
|
return false
|
|
}
|
|
domain := e[at+1:]
|
|
dot := strings.LastIndex(domain, ".")
|
|
return dot > 0 && dot < len(domain)-1
|
|
}
|
|
|
|
func (s *Server) session(r *http.Request) (*db.User, *db.Session) {
|
|
c, err := r.Cookie(sessionCookie)
|
|
if err != nil {
|
|
return nil, nil
|
|
}
|
|
sess, err := s.db.GetSession(c.Value)
|
|
if err != nil {
|
|
return nil, nil
|
|
}
|
|
u, err := s.db.GetUserByID(sess.UserID)
|
|
if err != nil {
|
|
return nil, nil
|
|
}
|
|
return u, sess
|
|
}
|
|
|
|
func (s *Server) sessionUser(r *http.Request) *db.User {
|
|
u, _ := s.session(r)
|
|
return u
|
|
}
|
|
|
|
func (s *Server) requireUser(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if s.sessionUser(r) == nil {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
u := s.sessionUser(r)
|
|
if u == nil || !u.IsAdmin {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
s.notFound(w, r)
|
|
return
|
|
}
|
|
s.render(w, "index.html", nil)
|
|
}
|
|
|
|
func (s *Server) handleHelp(w http.ResponseWriter, r *http.Request) {
|
|
s.render(w, "help.html", map[string]any{"User": s.sessionUser(r)})
|
|
}
|
|
|
|
func (s *Server) handleApplyGet(w http.ResponseWriter, r *http.Request) {
|
|
s.render(w, "apply.html", map[string]any{"Error": r.URL.Query().Get("err")})
|
|
}
|
|
|
|
func (s *Server) handleApplyPost(w http.ResponseWriter, r *http.Request) {
|
|
if !s.applyLimiter.allow(clientIP(r)) {
|
|
http.Redirect(w, r, "/apply?err=too+many+registration+attempts%2C+please+try+again+later", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
username := r.FormValue("username")
|
|
password := r.FormValue("password")
|
|
confirm := r.FormValue("password_confirm")
|
|
email := r.FormValue("email")
|
|
bio := r.FormValue("bio")
|
|
|
|
if username == "" || password == "" || email == "" {
|
|
http.Redirect(w, r, "/apply?err=username%2C+password+and+email+are+required", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if msg := usernameProblem(username); msg != "" {
|
|
back(w, r, "/apply", "err", msg)
|
|
return
|
|
}
|
|
if taken, err := s.db.NameTaken(username, 0); err != nil || taken {
|
|
http.Redirect(w, r, "/apply?err=username+already+taken", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if len(password) < 8 {
|
|
http.Redirect(w, r, "/apply?err=password+must+be+at+least+8+characters", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if password != confirm {
|
|
http.Redirect(w, r, "/apply?err=passwords+do+not+match", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if len(password) > 128 {
|
|
http.Redirect(w, r, "/apply?err=password+too+long", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if !validEmail(email) {
|
|
http.Redirect(w, r, "/apply?err=invalid+email+address", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if bio == "" {
|
|
http.Redirect(w, r, "/apply?err=biography+is+required", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if len(email) > 254 || len(bio) > 2000 {
|
|
http.Redirect(w, r, "/apply?err=input+too+long", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if r.FormValue("agree") != "on" {
|
|
http.Redirect(w, r, "/apply?err=you+must+agree+to+the+manifesto+and+privacy+policy", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
|
if err != nil {
|
|
s.internalError(w)
|
|
return
|
|
}
|
|
|
|
if _, err := s.db.CreateUser(username, string(hash), s.defaultNote); err != nil {
|
|
http.Redirect(w, r, "/apply?err=username+already+taken", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
subject := "[misaki] New application: " + username
|
|
body := fmt.Sprintf("Username: %s\nEmail: %s\nBiography:\n%s\n\nApplied: %s",
|
|
username, email, bio, time.Now().Format("2006-01-02 15:04 UTC"))
|
|
if err := s.smtp.notify(subject, body); err != nil {
|
|
log.Printf("notify apply (admin): %v", err)
|
|
}
|
|
confirmBody := fmt.Sprintf("Hi %s <3,\n\nYour application has been received I will get started on creating your personal contract right away! I will reach you at this address once I completed my review.\n\nBe seeing you soon :3", username)
|
|
if err := s.smtp.send(email, "[misaki] Application received", confirmBody); err != nil {
|
|
log.Printf("notify apply (confirm): %v", err)
|
|
} else {
|
|
log.Printf("notify apply (confirm): sent to %s", email)
|
|
}
|
|
}()
|
|
|
|
http.Redirect(w, r, "/applied", http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) handleApplied(w http.ResponseWriter, r *http.Request) {
|
|
s.render(w, "applied.html", nil)
|
|
}
|
|
|
|
func (s *Server) handleLoginGet(w http.ResponseWriter, r *http.Request) {
|
|
if s.sessionUser(r) != nil {
|
|
next := r.URL.Query().Get("next")
|
|
if s.isAllowedRedirect(next) {
|
|
http.Redirect(w, r, next, http.StatusSeeOther)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
|
return
|
|
}
|
|
s.render(w, "login.html", map[string]any{
|
|
"Error": r.URL.Query().Get("err"),
|
|
"Msg": r.URL.Query().Get("msg"),
|
|
"Next": r.URL.Query().Get("next"),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
|
if !s.loginLimiter.allow(clientIP(r)) {
|
|
http.Redirect(w, r, "/login?err=too+many+attempts%2C+please+try+again+later", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
username := r.FormValue("username")
|
|
password := r.FormValue("password")
|
|
|
|
u, dbErr := s.db.GetUserByUsername(username)
|
|
|
|
hash := dummyHash
|
|
if dbErr == nil {
|
|
hash = []byte(u.PassHash)
|
|
}
|
|
if bcrypt.CompareHashAndPassword(hash, []byte(password)) != nil || dbErr != nil {
|
|
http.Redirect(w, r, "/login?err=invalid+credentials", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
if u.Status != "approved" {
|
|
msg := "application+pending+approval"
|
|
if u.Status == "rejected" {
|
|
msg = "application+rejected"
|
|
}
|
|
http.Redirect(w, r, "/login?err="+msg, http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
if s.totpOn(u.ID) {
|
|
s.startPending(w, u.ID, r.FormValue("next"))
|
|
http.Redirect(w, r, "/login/code", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if err := s.startSession(w, u.ID, "pwd"); err != nil {
|
|
s.internalError(w)
|
|
return
|
|
}
|
|
s.redirectNext(w, r, r.FormValue("next"))
|
|
}
|
|
|
|
func (s *Server) startSession(w http.ResponseWriter, uid int64, amr string) error {
|
|
token, err := randomToken()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
expires := time.Now().Add(sessionTTL)
|
|
if err := s.db.CreateSession(token, uid, expires, amr); err != nil {
|
|
return err
|
|
}
|
|
|
|
if s.baseDomain != "" {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookie,
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
})
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookie,
|
|
Value: token,
|
|
Path: "/",
|
|
Domain: s.baseDomain,
|
|
HttpOnly: true,
|
|
Secure: s.secureCookies,
|
|
SameSite: http.SameSiteStrictMode,
|
|
Expires: expires,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) redirectNext(w http.ResponseWriter, r *http.Request, next string) {
|
|
if s.isAllowedRedirect(next) {
|
|
http.Redirect(w, r, next, http.StatusSeeOther)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) handleLogoutGet(w http.ResponseWriter, r *http.Request) {
|
|
if s.sessionUser(r) == nil {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
s.render(w, "logout.html", nil)
|
|
}
|
|
|
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
if c, err := r.Cookie(sessionCookie); err == nil {
|
|
_ = s.db.DeleteSession(c.Value)
|
|
}
|
|
// Delete both host-only and domain cookie variants
|
|
http.SetCookie(w, &http.Cookie{Name: sessionCookie, MaxAge: -1, Path: "/"})
|
|
if s.baseDomain != "" {
|
|
http.SetCookie(w, &http.Cookie{Name: sessionCookie, MaxAge: -1, Path: "/", Domain: s.baseDomain})
|
|
}
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|
u := s.sessionUser(r)
|
|
svcs, _ := s.db.GetServicesForUser(u.ID)
|
|
|
|
cards := make([]serviceCard, 0, len(svcs))
|
|
for _, sv := range svcs {
|
|
cards = append(cards, serviceCard{
|
|
Service: sv.Service,
|
|
Label: sv.Label,
|
|
Type: svcType[sv.Service],
|
|
Applied: sv.Applied,
|
|
Granted: sv.Granted,
|
|
Denied: sv.Denied,
|
|
DenyReason: sv.DenyReason,
|
|
Data: sv.Data,
|
|
Desc: s.svcDescs[sv.Service],
|
|
InfoURL: s.svcInfoURLs[sv.Service],
|
|
URL: s.svcCookieURLs[sv.Service],
|
|
NeedsEmail: s.needsEmail(sv.Service),
|
|
})
|
|
}
|
|
email, _ := s.db.GetUserEmail(u.ID)
|
|
codesLeft, _ := s.db.CountRecoveryCodes(u.ID)
|
|
|
|
s.render(w, "dashboard.html", map[string]any{
|
|
"User": u,
|
|
"Email": email,
|
|
"TOTPReady": s.totp != nil,
|
|
"TOTPOn": s.totpOn(u.ID),
|
|
"CodesLeft": codesLeft,
|
|
"Cards": cards,
|
|
"SvcDescs": s.svcDescs,
|
|
"InfoURLs": s.svcInfoURLs,
|
|
"URLs": s.svcURLs,
|
|
"Error": r.URL.Query().Get("err"),
|
|
"Success": r.URL.Query().Get("msg"),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleApplyService(w http.ResponseWriter, r *http.Request) {
|
|
u := s.sessionUser(r)
|
|
service := r.FormValue("service")
|
|
|
|
typ, ok := svcType[service]
|
|
if !ok || typ == "display" || typ == "link" || typ == "cookie" {
|
|
http.Redirect(w, r, "/dashboard?err=invalid+service", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
var handle, password string
|
|
reason := r.FormValue("reason")
|
|
if len(reason) > 2000 {
|
|
http.Redirect(w, r, "/dashboard?err=input+too+long", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if (service == "radio" || service == "streaming") && strings.TrimSpace(reason) == "" {
|
|
http.Redirect(w, r, "/dashboard?err=a+reason+is+required", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if typ == "form" {
|
|
handle = r.FormValue("handle")
|
|
password = r.FormValue("password")
|
|
if handle == "" || password == "" {
|
|
http.Redirect(w, r, "/dashboard?err=handle+and+password+required", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if len(handle) > 64 || len(password) > 128 {
|
|
http.Redirect(w, r, "/dashboard?err=input+too+long", http.StatusSeeOther)
|
|
return
|
|
}
|
|
}
|
|
|
|
email := strings.TrimSpace(r.FormValue("email"))
|
|
if typ == "oidc" {
|
|
if email == "" && s.needsEmail(service) {
|
|
email, _ = s.db.GetUserEmail(u.ID)
|
|
}
|
|
if email == "" && s.needsEmail(service) {
|
|
http.Redirect(w, r, "/dashboard?err=an+email+is+required+for+"+service, http.StatusSeeOther)
|
|
return
|
|
}
|
|
if email != "" {
|
|
if len(email) > 254 || !validEmail(email) {
|
|
http.Redirect(w, r, "/dashboard?err=invalid+email+address", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if err := s.db.SetUserEmail(u.ID, email); err != nil {
|
|
s.internalError(w)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := s.db.ApplyForService(u.ID, service); err != nil {
|
|
if errors.Is(err, db.ErrAlreadyApplied) {
|
|
http.Redirect(w, r, "/dashboard?err=already+applied+for+"+service, http.StatusSeeOther)
|
|
return
|
|
}
|
|
s.internalError(w)
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
subject := fmt.Sprintf("[misaki] Service request: %s / %s", u.Username, service)
|
|
reasonLine := reason
|
|
if reasonLine == "" {
|
|
reasonLine = "N/A"
|
|
}
|
|
var body string
|
|
if typ == "form" {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
|
|
hashStr := "(bcrypt failed)"
|
|
if err == nil {
|
|
hashStr = string(hash)
|
|
}
|
|
body = fmt.Sprintf(
|
|
"User: %s\nService: %s\nHandle: %s\nHash: %s\nReason: %s\n\nRequested: %s",
|
|
u.Username, service, handle, hashStr, reasonLine,
|
|
time.Now().Format("2006-01-02 15:04 UTC"))
|
|
} else {
|
|
body = fmt.Sprintf(
|
|
"User: %s\nService: %s\nEmail: %s\nReason: %s\n\nRequested: %s",
|
|
u.Username, service, email, reasonLine,
|
|
time.Now().Format("2006-01-02 15:04 UTC"))
|
|
}
|
|
if err := s.smtp.notify(subject, body); err != nil {
|
|
log.Printf("notify apply email: %v", err)
|
|
}
|
|
}()
|
|
|
|
http.Redirect(w, r, "/dashboard?msg=applied+for+"+service, http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) handleRequestReset(w http.ResponseWriter, r *http.Request) {
|
|
u := s.sessionUser(r)
|
|
service := r.FormValue("service")
|
|
|
|
if _, ok := svcType[service]; !ok {
|
|
http.Redirect(w, r, "/dashboard?err=invalid+service", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
subject := fmt.Sprintf("[misaki] Password reset request: %s / %s", u.Username, service)
|
|
body := fmt.Sprintf("User %s requests a password reset for service: %s\n\nSent: %s",
|
|
u.Username, service, time.Now().Format("2006-01-02 15:04 UTC"))
|
|
if err := s.smtp.notify(subject, body); err != nil {
|
|
log.Printf("notify reset email: %v", err)
|
|
}
|
|
}()
|
|
|
|
http.Redirect(w, r, "/dashboard?msg=reset+request+sent", http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) handleRequestNameChange(w http.ResponseWriter, r *http.Request) {
|
|
u := s.sessionUser(r)
|
|
service := r.FormValue("service")
|
|
newName := r.FormValue("new_name")
|
|
|
|
if _, ok := svcType[service]; !ok {
|
|
http.Redirect(w, r, "/dashboard?err=invalid+service", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if newName == "" {
|
|
http.Redirect(w, r, "/dashboard?err=new+name+is+required", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if len(newName) > 64 {
|
|
http.Redirect(w, r, "/dashboard?err=new+name+too+long", http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
subject := fmt.Sprintf("[misaki] Name change request: %s / %s", u.Username, service)
|
|
body := fmt.Sprintf("User %s requests a name change for service: %s\nRequested name: %s\n\nSent: %s",
|
|
u.Username, service, newName, time.Now().Format("2006-01-02 15:04 UTC"))
|
|
if err := s.smtp.notify(subject, body); err != nil {
|
|
log.Printf("notify namechange email: %v", err)
|
|
}
|
|
}()
|
|
|
|
http.Redirect(w, r, "/dashboard?msg=name+change+request+sent", http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
|
u := s.sessionUser(r)
|
|
note := r.FormValue("note")
|
|
if len(note) > 2000 {
|
|
http.Redirect(w, r, "/dashboard?err=note+too+long+(max+2000+characters)", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if err := s.db.UpdateUserNote(u.ID, note); err != nil {
|
|
s.internalError(w)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/dashboard?msg=note+updated", http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) handleUpdateEmail(w http.ResponseWriter, r *http.Request) {
|
|
u := s.sessionUser(r)
|
|
email := strings.TrimSpace(r.FormValue("email"))
|
|
if email != "" && (len(email) > 254 || !validEmail(email)) {
|
|
http.Redirect(w, r, "/dashboard?err=invalid+email+address", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if err := s.db.SetUserEmail(u.ID, email); err != nil {
|
|
s.internalError(w)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/dashboard?msg=email+updated", http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) needsEmail(service string) bool {
|
|
if s.oidc == nil {
|
|
return false
|
|
}
|
|
for _, c := range s.oidc.clients {
|
|
if c.Service == service && c.RequireEmail {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
|
pending, _ := s.db.ListUsers("pending")
|
|
approved, _ := s.db.ListUsers("approved")
|
|
rejected, _ := s.db.ListUsers("rejected")
|
|
dbServices, _ := s.db.ListServices()
|
|
|
|
type request struct {
|
|
User *db.User
|
|
Svc *db.UserService
|
|
}
|
|
userServices := make(map[int64][]*db.UserService)
|
|
waiting := make(map[int64]int)
|
|
var requests []request
|
|
for _, u := range approved {
|
|
svcs, _ := s.db.ListUserServices(u.ID)
|
|
userServices[u.ID] = svcs
|
|
for _, sv := range svcs {
|
|
if sv.Applied && !sv.Granted {
|
|
requests = append(requests, request{u, sv})
|
|
waiting[u.ID]++
|
|
}
|
|
}
|
|
}
|
|
|
|
openID, _ := strconv.ParseInt(r.URL.Query().Get("open"), 10, 64)
|
|
|
|
s.render(w, "admin.html", map[string]any{
|
|
"Pending": pending,
|
|
"Approved": approved,
|
|
"Rejected": rejected,
|
|
"Services": dbServices,
|
|
"UserServices": userServices,
|
|
"Requests": requests,
|
|
"Waiting": waiting,
|
|
"DataHints": adminDataHints,
|
|
"OpenID": openID,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleAdminAction(w http.ResponseWriter, r *http.Request) {
|
|
idStr := r.FormValue("id")
|
|
action := r.FormValue("action")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
|
|
statuses := map[string]string{
|
|
"approve": "approved",
|
|
"reject": "rejected",
|
|
"reinstate": "approved",
|
|
}
|
|
status, ok := statuses[action]
|
|
if err != nil || !ok {
|
|
http.Error(w, "bad request", 400)
|
|
return
|
|
}
|
|
|
|
if dbErr := s.db.SetUserStatus(id, status); dbErr != nil && !errors.Is(dbErr, sql.ErrNoRows) {
|
|
s.internalError(w)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) handleAdminToggleService(w http.ResponseWriter, r *http.Request) {
|
|
service := r.FormValue("service")
|
|
enabled := r.FormValue("enabled") == "1"
|
|
if service == "" {
|
|
http.Error(w, "bad request", 400)
|
|
return
|
|
}
|
|
if err := s.db.SetServiceEnabled(service, enabled); err != nil {
|
|
s.internalError(w)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) handleAdminUserService(w http.ResponseWriter, r *http.Request) {
|
|
idStr := r.FormValue("user_id")
|
|
service := r.FormValue("service")
|
|
action := r.FormValue("action")
|
|
|
|
userID, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil || service == "" {
|
|
http.Error(w, "bad request", 400)
|
|
return
|
|
}
|
|
|
|
switch action {
|
|
case "grant":
|
|
err = s.db.SetUserServiceEnabled(userID, service, true)
|
|
case "revoke":
|
|
err = s.db.SetUserServiceEnabled(userID, service, false)
|
|
case "setlink":
|
|
data := r.FormValue("data")
|
|
err = s.db.SetUserServiceData(userID, service, data)
|
|
case "deny":
|
|
reason := strings.Join(strings.Fields(r.FormValue("reason")), " ")
|
|
if len(reason) > 280 {
|
|
http.Error(w, "reason too long", 400)
|
|
return
|
|
}
|
|
err = s.db.DenyService(userID, service, reason)
|
|
default:
|
|
http.Error(w, "bad request", 400)
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
s.internalError(w)
|
|
return
|
|
}
|
|
http.Redirect(w, r, fmt.Sprintf("/admin?open=%d#user-%d", userID, userID), http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) internalAuthorized(r *http.Request) bool {
|
|
auth := r.Header.Get("Authorization")
|
|
if s.internalSecret == "" || !strings.HasPrefix(auth, "Bearer ") {
|
|
return false
|
|
}
|
|
return subtle.ConstantTimeCompare([]byte(auth[len("Bearer "):]), []byte(s.internalSecret)) == 1
|
|
}
|
|
|
|
func (s *Server) handleInternalAuth(w http.ResponseWriter, r *http.Request) {
|
|
if !s.internalAuthorized(r) {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
Service string `json:"service"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Username == "" || req.Password == "" {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if len(req.Password) > 128 {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
limitKey := req.Service + ":" + strings.ToLower(req.Username)
|
|
if req.Service != "" && s.authLimiter.blocked(limitKey) {
|
|
http.Error(w, "too many attempts", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
fail := func(msg string, code int) {
|
|
if req.Service != "" {
|
|
s.authLimiter.record(limitKey)
|
|
}
|
|
http.Error(w, msg, code)
|
|
}
|
|
|
|
lookup := s.db.GetUserByUsername
|
|
if req.Service != "" {
|
|
lookup = s.db.GetUserByUsernameFold
|
|
}
|
|
u, dbErr := lookup(req.Username)
|
|
hash := dummyHash
|
|
if dbErr == nil {
|
|
hash = []byte(u.PassHash)
|
|
}
|
|
if bcrypt.CompareHashAndPassword(hash, []byte(req.Password)) != nil || dbErr != nil {
|
|
fail("unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if u.Status != "approved" {
|
|
fail("forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
if req.Service != "" {
|
|
enabled, _, err := s.db.GetUserService(u.ID, req.Service)
|
|
if err != nil {
|
|
s.internalError(w)
|
|
return
|
|
}
|
|
if !enabled {
|
|
fail("forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"username": u.Username})
|
|
}
|
|
|
|
func (s *Server) handleInternalUsers(w http.ResponseWriter, r *http.Request) {
|
|
if !s.internalAuthorized(r) {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
service := r.URL.Query().Get("service")
|
|
if service == "" {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
names, err := s.db.ListServiceUsernames(service)
|
|
if err != nil {
|
|
s.internalError(w)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string][]string{"users": names})
|
|
}
|
|
|
|
func stationAllowed(data, mount string) bool {
|
|
for _, st := range strings.Split(data, ",") {
|
|
st = strings.TrimSpace(st)
|
|
if st == mount || st == "*" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Server) copyGroups(u *db.User) []string {
|
|
seen := map[string]bool{}
|
|
var groups []string
|
|
add := func(g string) {
|
|
if g != "" && !seen[g] {
|
|
seen[g] = true
|
|
groups = append(groups, g)
|
|
}
|
|
}
|
|
add("users")
|
|
if u.IsAdmin {
|
|
add("admin")
|
|
}
|
|
if _, data, err := s.db.GetUserService(u.ID, "files"); err == nil {
|
|
for _, g := range strings.Split(data, ",") {
|
|
g = strings.TrimSpace(g)
|
|
if g == "*" {
|
|
add("admin")
|
|
} else {
|
|
add(g)
|
|
}
|
|
}
|
|
}
|
|
return groups
|
|
}
|
|
|
|
func (s *Server) handleInternalStreamAuth(w http.ResponseWriter, r *http.Request) {
|
|
if !s.internalAuthorized(r) {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
Mount string `json:"mount"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil ||
|
|
req.Username == "" || req.Password == "" || req.Mount == "" {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if len(req.Password) > 128 {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if s.streamLimiter.blocked(req.Username) {
|
|
http.Error(w, "too many attempts", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
|
|
u, dbErr := s.db.GetUserByUsername(req.Username)
|
|
hash := dummyHash
|
|
if dbErr == nil {
|
|
hash = []byte(u.PassHash)
|
|
}
|
|
if bcrypt.CompareHashAndPassword(hash, []byte(req.Password)) != nil || dbErr != nil {
|
|
s.streamLimiter.record(req.Username)
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if u.Status != "approved" {
|
|
s.streamLimiter.record(req.Username)
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if !u.IsAdmin {
|
|
enabled, data, err := s.db.GetUserService(u.ID, "radio")
|
|
if err != nil {
|
|
s.internalError(w)
|
|
return
|
|
}
|
|
if !enabled || !stationAllowed(data, req.Mount) {
|
|
s.streamLimiter.record(req.Username)
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
func (s *Server) handleInternalSessionCheck(w http.ResponseWriter, r *http.Request) {
|
|
c, err := r.Cookie(sessionCookie)
|
|
if err != nil || c.Value == "" {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
sess, err := s.db.GetSession(c.Value)
|
|
if err != nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
u, err := s.db.GetUserByID(sess.UserID)
|
|
if err != nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if u.Status != "approved" {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
if service := r.Header.Get("X-Auth-Service"); service != "" && !u.IsAdmin {
|
|
enabled, _, err := s.db.GetUserService(u.ID, service)
|
|
if err != nil {
|
|
s.internalError(w)
|
|
return
|
|
}
|
|
if !enabled {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
|
|
w.Header().Set("X-Auth-User", u.Username)
|
|
if groups := s.copyGroups(u); len(groups) > 0 {
|
|
w.Header().Set("X-Auth-Groups", strings.Join(groups, ","))
|
|
}
|
|
s.signAuth(w, u.Username)
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
func (s *Server) signAuth(w http.ResponseWriter, username string) {
|
|
if len(s.authSecret) == 0 {
|
|
return
|
|
}
|
|
exp := time.Now().Add(2 * time.Minute).Unix()
|
|
mac := hmac.New(sha256.New, s.authSecret)
|
|
fmt.Fprintf(mac, "v1:%s:%d", username, exp)
|
|
w.Header().Set("X-Auth-Exp", strconv.FormatInt(exp, 10))
|
|
w.Header().Set("X-Auth-Sig", base64.RawURLEncoding.EncodeToString(mac.Sum(nil)))
|
|
}
|