summaryrefslogtreecommitdiff
path: root/api/auth
diff options
context:
space:
mode:
Diffstat (limited to 'api/auth')
-rw-r--r--api/auth/auth.go280
1 files changed, 280 insertions, 0 deletions
diff --git a/api/auth/auth.go b/api/auth/auth.go
new file mode 100644
index 0000000..a70af32
--- /dev/null
+++ b/api/auth/auth.go
@@ -0,0 +1,280 @@
+package auth
+
+import (
+ "errors"
+ "log"
+ "net"
+ "net/http"
+ "time"
+
+ "penguins.lan/adapters/arp"
+ "penguins.lan/api/types"
+ "penguins.lan/database"
+ "penguins.lan/utils"
+)
+
+const (
+ sessionCookie = "penguin_session"
+ sessionTTL = 30 * 24 * time.Hour
+)
+
+func ClientIP(req *http.Request) string {
+ host, _, err := net.SplitHostPort(req.RemoteAddr)
+ if err != nil {
+ return req.RemoteAddr
+ }
+ return host
+}
+
+// clientMac resolves the visitor's MAC from the DEV_MAC override or the ARP
+// cache the scheduler keeps fresh. "" means we don't know it.
+func clientMac(context *types.RequestContext, req *http.Request) string {
+ if dev := context.Args.DevMAC; dev != "" {
+ if mac, err := utils.ParseMac(dev); err == nil {
+ return mac.Format()
+ }
+ return ""
+ }
+ ip := ClientIP(req)
+ mac, err := database.MacForIP(context.DBConn, ip)
+ if err != nil {
+ log.Println("mac lookup failed:", err)
+ }
+ if mac != "" {
+ return mac
+ }
+ return liveMac(ip)
+}
+
+// liveMac falls back to a direct ARP read for a brand-new connection the
+// scheduler hasn't cached yet. Non-IPv4 peers (e.g. loopback) are skipped so we
+// don't shell out for addresses that can never resolve.
+func liveMac(ip string) string {
+ if _, err := utils.ParseINet4(ip); err != nil {
+ return ""
+ }
+ table, err := arp.SystemProvider{}.Fetch()
+ if err != nil {
+ log.Println("live arp lookup failed:", err)
+ return ""
+ }
+ mac, _ := table.Lookup(ip)
+ return mac
+}
+
+func setSessionCookie(resp http.ResponseWriter, id string) {
+ http.SetCookie(resp, &http.Cookie{
+ Name: sessionCookie,
+ Value: id,
+ Path: "/",
+ MaxAge: int(sessionTTL.Seconds()),
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+func clearSessionCookie(resp http.ResponseWriter) {
+ http.SetCookie(resp, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
+}
+
+func LoginUser(context *types.RequestContext, resp http.ResponseWriter, user *database.User) error {
+ session, err := database.CreateSession(context.DBConn, utils.RandomId(), user.ID, sessionTTL)
+ if err != nil {
+ return err
+ }
+ setSessionCookie(resp, session.ID)
+ context.User = user
+ context.Nick = user.Username
+ return nil
+}
+
+func ResolveSessionContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ if cookie, err := req.Cookie(sessionCookie); err == nil && cookie.Value != "" {
+ if user, err := database.GetSessionUser(context.DBConn, cookie.Value); err == nil {
+ context.User = user
+ context.Nick = user.Username
+ _ = database.TouchUser(context.DBConn, user.ID)
+ return success(context, req, resp)
+ }
+ }
+
+ mac := clientMac(context, req)
+ context.ClientMAC = mac
+
+ if mac != "" {
+ if user, err := database.GetUserByMAC(context.DBConn, mac); err == nil {
+ if err := LoginUser(context, resp, user); err != nil {
+ log.Println("mac auto-login failed:", err)
+ } else {
+ _ = database.TouchUser(context.DBConn, user.ID)
+ }
+ }
+ }
+
+ return success(context, req, resp)
+ }
+}
+
+func RenameContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ if context.User == nil {
+ return success(context, req, resp)
+ }
+
+ newName := utils.SanitizeName(req.FormValue("username"))
+ if newName == "" || newName == context.User.Username {
+ http.Redirect(resp, req, "/", http.StatusSeeOther)
+ return success(context, req, resp)
+ }
+
+ renameError := func(message string) types.ContinuationChain {
+ (*context.TemplateData)["Error"] = types.BannerMessages{Messages: []string{message}}
+ return failure(context, req, resp)
+ }
+
+ switch existing, err := database.GetUserByUsername(context.DBConn, newName); {
+ case err == nil && existing.ID != context.User.ID:
+ return renameError("that handle's taken.")
+ case err != nil && !errors.Is(err, database.ErrNotFound):
+ log.Println("rename lookup failed:", err)
+ return renameError("something broke, try again")
+ }
+
+ if err := database.RenameUser(context.DBConn, context.User.ID, newName); err != nil {
+ log.Println("rename failed:", err)
+ return renameError("couldn't rename, try again")
+ }
+
+ context.User.Username = newName
+ context.Nick = newName
+ http.Redirect(resp, req, "/", http.StatusSeeOther)
+ return success(context, req, resp)
+ }
+}
+
+func RequireUserContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ if context.User != nil {
+ return success(context, req, resp)
+ }
+ return failure(context, req, resp)
+ }
+}
+
+func GoPortalContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ http.Redirect(resp, req, "/portal", http.StatusSeeOther)
+ return success(context, req, resp)
+ }
+}
+
+func UnauthorizedContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ resp.WriteHeader(http.StatusUnauthorized)
+ return success(context, req, resp)
+ }
+}
+
+func LogoutContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain {
+ if cookie, err := req.Cookie(sessionCookie); err == nil && cookie.Value != "" {
+ _ = database.DeleteSession(context.DBConn, cookie.Value)
+ }
+ clearSessionCookie(resp)
+ http.Redirect(resp, req, "/portal", http.StatusSeeOther)
+ return success(context, req, resp)
+ }
+}
+
+type portalView struct {
+ SuggestedName string
+ RequestedName string
+ DetectedMAC string
+ Taken bool
+ LastSeen string
+}
+
+func ShowPortalContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ if context.User != nil {
+ http.Redirect(resp, req, "/", http.StatusSeeOther)
+ return failure(context, req, resp)
+ }
+ (*context.TemplateData)["Portal"] = portalView{
+ SuggestedName: utils.SuggestName(),
+ DetectedMAC: context.ClientMAC,
+ }
+ return success(context, req, resp)
+ }
+}
+
+func SetupUserContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain {
+ return func(success types.Continuation, failure types.Continuation) types.ContinuationChain {
+ username := utils.SanitizeName(req.FormValue("username"))
+ reclaim := req.FormValue("reclaim") == "1"
+
+ mac := clientMac(context, req)
+ context.ClientMAC = mac
+
+ view := portalView{
+ SuggestedName: utils.SuggestName(),
+ RequestedName: username,
+ DetectedMAC: mac,
+ }
+
+ fail := func(message string) types.ContinuationChain {
+ if message != "" {
+ (*context.TemplateData)["Error"] = types.BannerMessages{Messages: []string{message}}
+ }
+ (*context.TemplateData)["Portal"] = view
+ return failure(context, req, resp)
+ }
+
+ if username == "" {
+ resp.WriteHeader(http.StatusBadRequest)
+ return fail("pick a handle, any handle 🐧")
+ }
+
+ existing, err := database.GetUserByUsername(context.DBConn, username)
+ switch {
+ case errors.Is(err, database.ErrNotFound):
+ user, err := database.CreateUser(context.DBConn, utils.RandomId(), username)
+ if err != nil {
+ log.Println("create user failed:", err)
+ resp.WriteHeader(http.StatusInternalServerError)
+ return fail("couldn't create your account, try again")
+ }
+ bindAndFinish(context, resp, user, mac)
+ http.Redirect(resp, req, "/", http.StatusSeeOther)
+ return success(context, req, resp)
+
+ case err != nil:
+ log.Println("lookup user failed:", err)
+ resp.WriteHeader(http.StatusInternalServerError)
+ return fail("something broke, try again")
+
+ default:
+ if !reclaim {
+ view.Taken = true
+ view.LastSeen = utils.FormatSince(existing.LastSeen)
+ return fail("")
+ }
+ bindAndFinish(context, resp, existing, mac)
+ http.Redirect(resp, req, "/", http.StatusSeeOther)
+ return success(context, req, resp)
+ }
+ }
+}
+
+func bindAndFinish(context *types.RequestContext, resp http.ResponseWriter, user *database.User, mac string) {
+ if mac != "" {
+ if err := database.AssociateMAC(context.DBConn, mac, user.ID); err != nil {
+ log.Println("associate mac failed:", err)
+ }
+ }
+ _ = database.TouchUser(context.DBConn, user.ID)
+ if err := LoginUser(context, resp, user); err != nil {
+ log.Println("login failed:", err)
+ }
+}