From fb653292612eddca5b088ed88466c546d1597107 Mon Sep 17 00:00:00 2001 From: Elizabeth Alexander Hunt Date: Sat, 20 Jun 2026 09:40:07 -0700 Subject: Initial commit --- api/auth/auth.go | 280 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 api/auth/auth.go (limited to 'api/auth/auth.go') 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) + } +} -- cgit v1.3