CI / test (push) Failing after 42s
People sign up on the website and link IMVU accounts by putting a code in the account's tagline, which the hub reads from IMVU's public API (the most recent proof wins; link caps per person). IMVU's age verification or Access Pass make an account adult. T5DE's proxy signs in with a device code and keeps a WebSocket open, reporting the IMVU account in use and the community products it wears; /online shows those who opted in. Moderators get a console: who's online, people, link caps, bans, roles, and the audit log. Tests run the website and API against an embedded Postgres and a fake IMVU API. Co-Authored-By: Claude Opus 5.5 <[email protected]>
217 lines
6.3 KiB
Go
217 lines
6.3 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.t5de.dev/t5de/t5de-hub/internal/store"
|
|
"git.t5de.dev/t5de/t5de-hub/internal/token"
|
|
)
|
|
|
|
// The proxy signs in like a TV app (OAuth 2.0 device authorization, RFC 8628): it asks for a code, the person
|
|
// enters it on the website, and the proxy gets tokens: a short-lived access token (Ed25519 JWT) for the API, and
|
|
// a refresh token it trades for new ones.
|
|
|
|
const (
|
|
deviceLifetime = 10 * time.Minute
|
|
accessLifetime = 15 * time.Minute
|
|
)
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func apiError(w http.ResponseWriter, status int, code, description string) {
|
|
writeJSON(w, status, map[string]string{"error": code, "error_description": description})
|
|
}
|
|
|
|
// userCode is what a person types: 8 letters, no look-alikes, shown as XXXX-XXXX.
|
|
func userCode() string {
|
|
const alphabet = "BCDFGHJKLMNPQRSTVWXZ"
|
|
b := make([]byte, 8)
|
|
rand.Read(b)
|
|
for i := range b {
|
|
b[i] = alphabet[int(b[i])%len(alphabet)]
|
|
}
|
|
return string(b[:4]) + "-" + string(b[4:])
|
|
}
|
|
|
|
func normalizeUserCode(code string) string {
|
|
code = strings.ToUpper(strings.Map(func(r rune) rune {
|
|
if r == ' ' || r == '-' {
|
|
return -1
|
|
}
|
|
return r
|
|
}, code))
|
|
if len(code) == 8 {
|
|
code = code[:4] + "-" + code[4:]
|
|
}
|
|
return code
|
|
}
|
|
|
|
func (s *Server) deviceCode(w http.ResponseWriter, r *http.Request) {
|
|
if !s.limiter.allow("device:"+clientIP(r), 20, time.Hour) {
|
|
apiError(w, http.StatusTooManyRequests, "slow_down", "too many sign-ins from here")
|
|
return
|
|
}
|
|
device := token.Random(32)
|
|
var code string
|
|
for range 5 {
|
|
code = userCode()
|
|
err := s.Store.CreateDeviceCode(r.Context(), device, code, time.Now().Add(deviceLifetime))
|
|
if err == nil {
|
|
break
|
|
}
|
|
if !errors.Is(err, store.ErrTaken) {
|
|
s.Log.Printf("device code: %v", err)
|
|
apiError(w, http.StatusInternalServerError, "server_error", "")
|
|
return
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"device_code": device, "user_code": code, "expires_in": int(deviceLifetime.Seconds()), "interval": 3,
|
|
"verification_uri": s.PublicURL + "/device",
|
|
"verification_uri_complete": s.PublicURL + "/device?code=" + code,
|
|
})
|
|
}
|
|
|
|
func (s *Server) issue(ctx context.Context, w http.ResponseWriter, personID int64, refresh string) {
|
|
access, err := s.Keys.Issue("person:"+strconv.FormatInt(personID, 10), Audience, "proxy", accessLifetime)
|
|
if err != nil {
|
|
apiError(w, http.StatusInternalServerError, "server_error", "")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"access_token": access, "token_type": "Bearer",
|
|
"expires_in": int(accessLifetime.Seconds()), "refresh_token": refresh})
|
|
}
|
|
|
|
func (s *Server) deviceToken(w http.ResponseWriter, r *http.Request) {
|
|
status, personID, err := s.Store.ConsumeDeviceCode(r.Context(), r.PostFormValue("device_code"))
|
|
if err != nil {
|
|
s.Log.Printf("device token: %v", err)
|
|
apiError(w, http.StatusInternalServerError, "server_error", "")
|
|
return
|
|
}
|
|
switch status {
|
|
case store.DevicePending:
|
|
apiError(w, http.StatusBadRequest, "authorization_pending", "waiting for the code to be entered")
|
|
return
|
|
case store.DeviceGone:
|
|
apiError(w, http.StatusBadRequest, "expired_token", "the code expired or was used")
|
|
return
|
|
}
|
|
p, err := s.Store.PersonByID(r.Context(), personID)
|
|
if err != nil || p.Banned() {
|
|
apiError(w, http.StatusForbidden, "access_denied", "")
|
|
return
|
|
}
|
|
refresh := token.Random(32)
|
|
if err := s.Store.CreateRefreshToken(r.Context(), refresh, personID); err != nil {
|
|
apiError(w, http.StatusInternalServerError, "server_error", "")
|
|
return
|
|
}
|
|
s.issue(r.Context(), w, personID, refresh)
|
|
}
|
|
|
|
func (s *Server) refresh(w http.ResponseWriter, r *http.Request) {
|
|
replacement := token.Random(32)
|
|
personID, err := s.Store.RotateRefreshToken(r.Context(), r.PostFormValue("refresh_token"), replacement)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
apiError(w, http.StatusUnauthorized, "invalid_grant", "signed out")
|
|
return
|
|
}
|
|
if err != nil {
|
|
apiError(w, http.StatusInternalServerError, "server_error", "")
|
|
return
|
|
}
|
|
p, err := s.Store.PersonByID(r.Context(), personID)
|
|
if err != nil || p.Banned() {
|
|
apiError(w, http.StatusForbidden, "access_denied", "")
|
|
return
|
|
}
|
|
s.issue(r.Context(), w, personID, replacement)
|
|
}
|
|
|
|
// bearer is the person an API request's access token is for.
|
|
func (s *Server) bearer(r *http.Request) (*store.Person, error) {
|
|
auth := r.Header.Get("Authorization")
|
|
tok, ok := strings.CutPrefix(auth, "Bearer ")
|
|
if !ok {
|
|
return nil, errors.New("no token")
|
|
}
|
|
c, err := s.Keys.Check(tok, Audience)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
id, err := strconv.ParseInt(strings.TrimPrefix(c.Subject, "person:"), 10, 64)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
p, err := s.Store.PersonByID(r.Context(), id)
|
|
if err != nil || p.Banned() {
|
|
return nil, errors.New("no such person")
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
|
|
p, err := s.bearer(r)
|
|
if err != nil {
|
|
apiError(w, http.StatusUnauthorized, "invalid_token", "")
|
|
return
|
|
}
|
|
links, err := s.Store.Links(r.Context(), p.ID)
|
|
if err != nil {
|
|
apiError(w, http.StatusInternalServerError, "server_error", "")
|
|
return
|
|
}
|
|
type link struct {
|
|
IMVUID int64 `json:"imvu_id"`
|
|
Username string `json:"username"`
|
|
Adult bool `json:"adult"`
|
|
}
|
|
out := []link{}
|
|
for _, l := range links {
|
|
out = append(out, link{l.IMVUID, l.Username, l.Adult()})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"username": p.Username, "links": out})
|
|
}
|
|
|
|
func (s *Server) realtime(w http.ResponseWriter, r *http.Request) {
|
|
p, err := s.bearer(r)
|
|
if err != nil {
|
|
apiError(w, http.StatusUnauthorized, "invalid_token", "")
|
|
return
|
|
}
|
|
s.Realtime.Serve(w, r, p.ID)
|
|
}
|
|
|
|
func (s *Server) jwks(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Cache-Control", "public, max-age=300")
|
|
w.Write(s.Keys.JWKS())
|
|
}
|
|
|
|
// LinkOwner implements realtime.Owners.
|
|
type Owners struct{ Store *store.Store }
|
|
|
|
func (o Owners) LinkOwner(ctx context.Context, imvuID int64) (int64, bool, error) {
|
|
l, err := o.Store.LinkByIMVUID(ctx, imvuID)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return 0, false, nil
|
|
}
|
|
if err != nil {
|
|
return 0, false, err
|
|
}
|
|
return l.PersonID, true, nil
|
|
}
|