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") } oldName := context.User.Username if err := database.RenameUser(context.DBConn, context.User.ID, newName); err != nil { log.Println("rename failed:", err) return renameError("couldn't rename, try again") } // Device records are synthesized from the current username and follow // the rename for free; stored records have the old name baked in, so // move them to the new subdomain to keep the user's setup working. zone := context.Args.DnsZone if err := database.RenameUserDNSDomain(context.DBConn, context.User.ID, oldName+"."+zone, newName+"."+zone); err != nil { log.Println("rename dns records failed:", err) } 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) } // First device a user binds becomes their primary automatically. The // conditional update is atomic, so this stays correct even if two // devices bind concurrently. if err := database.SetPrimaryMACIfUnset(context.DBConn, user.ID, mac); err != nil { log.Println("set primary mac failed:", err) } } _ = database.TouchUser(context.DBConn, user.ID) if err := LoginUser(context, resp, user); err != nil { log.Println("login failed:", err) } }