diff --git a/README.md b/README.md index 8cec0b7..c650a8e 100644 --- a/README.md +++ b/README.md @@ -20,20 +20,6 @@ The website can also run out of a local SQLite database which is convenient for local development. The production server runs on PostgreSQL and the web app is primarily designed for that. -### PostGIS Extension for PostgreSQL - -For the "Who's Nearby" feature to work you will need a PostgreSQL -database with the PostGIS geospatial extension installed. Usually -it might be a matter of `dnf install postgis` and activating the -extension on your nonshy database as your superuser (postgres): - -```psql -create extension postgis; -``` - -If you get errors like "Type geography not found" from Postgres when -running distance based searches, this is the likely culprit. - ## Building the App This app is written in Go: [go.dev](https://go.dev). You can probably @@ -61,6 +47,96 @@ a database. For simple local development, just set `"UseSQLite": true` and the app will run with a SQLite database. +### Postgres is Highly Recommended + +This website is intended to run under PostgreSQL and some of its +features leverage Postgres specific extensions. For quick local +development, SQLite will work fine but some website features will +be disabled and error messages given. These include: + +* Location features such as "Who's Nearby" (PostGIS extension) +* "Newest" tab on the forums: to deduplicate comments by most recent + thread depends on Postgres, SQLite will always show all latest + comments without deduplication. + +### PostGIS Extension for PostgreSQL + +For the "Who's Nearby" feature to work you will need a PostgreSQL +database with the PostGIS geospatial extension installed. Usually +it might be a matter of `dnf install postgis` and activating the +extension on your nonshy database as your superuser (postgres): + +```psql +create extension postgis; +``` + +If you get errors like "Type geography not found" from Postgres when +running distance based searches, this is the likely culprit. + +### Signed Photo URLs (NGINX) + +The website supports "signed photo" URLs that can help protect the direct +links to user photos (their /static/photos/*.jpg paths) to ensure only +logged-in and authorized users are able to access those links. + +This feature is not enabled (enforcing) by default, as it relies on +cooperation with the NGINX reverse proxy server +(module ngx_http_auth_request). + +In your NGINX config, set your /static/ path to leverage NGINX auth_request +like so: + +```nginx +server { + # your boilerplate server info (SSL, etc.) - not relevant to this example. + listen 80 default_server; + listen [::]:80 default_server; + + # Relevant: setting the /static/ URL on NGINX to be an alias to your local + # nonshy static folder on disk. In this example, the git clone for the + # website was at /home/www-user/git/nonshy/website, so that ./web/static/ + # is the local path where static files (e.g., photos) are uploaded. + location /static/ { + # Important: auth_request tells NGINX to do subrequest authentication + # on requests into the /static/ URI of your website. + auth_request /static-auth; + + # standard NGINX alias commands. + alias /home/www-user/git/nonshy/website/web/static/; + autoindex off; + } + + # Configure the internal subrequest auth path. + # Note: the path "/static-auth" can be anything you want. + location = /static-auth { + internal; # this is an internal route for NGINX only, not public + + # Proxy to the /v1/auth/static URL on the web app. + # This line assumes the website runs on localhost:8080. + proxy_pass http://localhost:8080/v1/auth/static; + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + + # Important: the X-Original-URI header tells the web app what the + # original path (e.g. /static/photos/*) was, so the web app knows + # which sub-URL to enforce authentication on. + proxy_set_header X-Original-URI $request_uri; + } +} +``` + +When your NGINX config is set up like the above, you can edit the +settings.json to mark SignedPhoto/Enabled=true, and restart the +website. Be sure to test it! + +On a photo gallery view, all image URLs under /static/photos/ should +come with a ?jwt= parameter, and the image should load for the current +user. The JWT token is valid for 30 seconds after which the direct link +to the image should expire and give a 403 Forbidden response. + +When this feature is NOT enabled/not enforcing: the jwt= parameter is +still generated on photo URLs but is not enforced by the web app. + ## Usage The `nonshy` binary has sub-commands to either run the web server diff --git a/pkg/config/config.go b/pkg/config/config.go index 04c1ac3..7e42110 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -38,6 +38,9 @@ const ( TwoFactorBackupCodeCount = 12 TwoFactorBackupCodeLength = 8 // characters a-z0-9 + + // Signed URLs for static photo authentication. + SignedPhotoJWTExpires = 30 * time.Second ) // Authentication diff --git a/pkg/config/variable.go b/pkg/config/variable.go index 3edad1b..2b21a94 100644 --- a/pkg/config/variable.go +++ b/pkg/config/variable.go @@ -15,7 +15,7 @@ import ( // Version of the config format - when new fields are added, it will attempt // to write the settings.toml to disk so new defaults populate. -var currentVersion = 4 +var currentVersion = 5 // Current loaded settings.json var Current = DefaultVariable() @@ -32,6 +32,7 @@ type Variable struct { BareRTC BareRTC Maintenance Maintenance Encryption Encryption + SignedPhoto SignedPhoto WebPush WebPush Turnstile Turnstile UseXForwardedFor bool @@ -126,6 +127,12 @@ func LoadSettings() { writeSettings = true } + // Initialize JWT token for SignedPhoto feature. + if Current.SignedPhoto.JWTSecret == "" { + Current.SignedPhoto.JWTSecret = uuid.New().String() + writeSettings = true + } + // Have we added new config fields? Save the settings.json. if Current.Version != currentVersion || writeSettings { log.Warn("New options are available for your settings.json file. Your settings will be re-saved now.") @@ -196,6 +203,12 @@ type Encryption struct { ColdStorageRSAPublicKey []byte } +// SignedPhoto settings. +type SignedPhoto struct { + Enabled bool + JWTSecret string +} + // WebPush settings. type WebPush struct { VAPIDPublicKey string diff --git a/pkg/controller/api/static_auth.go b/pkg/controller/api/static_auth.go new file mode 100644 index 0000000..9217ad7 --- /dev/null +++ b/pkg/controller/api/static_auth.go @@ -0,0 +1,84 @@ +package api + +import ( + "fmt" + "net/http" + "net/url" + "strings" + + "code.nonshy.com/nonshy/website/pkg/config" + "code.nonshy.com/nonshy/website/pkg/encryption" + "code.nonshy.com/nonshy/website/pkg/session" + "github.com/golang-jwt/jwt/v4" +) + +// StaticAuth API protects paths like /static/photos/ to authenticated user requests only. +func StaticAuth() http.HandlerFunc { + type Response struct { + Success bool `json:"success"` + Error string `json:",omitempty"` + Username string `json:"username"` + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // We only protect the /static/photos subpath. + // And check if the SignedPhoto feature is enabled and enforcing. + var originalURI = r.Header.Get("X-Original-URI") + if !config.Current.SignedPhoto.Enabled || !strings.HasPrefix(originalURI, config.PhotoWebPath) { + SendJSON(w, http.StatusOK, Response{ + Success: true, + }) + return + } + + // Parse the JWT token parameter from the original URL. + var token string + if path, err := url.Parse(originalURI); err == nil { + query := path.Query() + token = query.Get("jwt") + } + + // The token is required from here. + if token == "" { + SendJSON(w, http.StatusForbidden, Response{ + Error: "JWT token is required", + }) + return + } + + // Check if we're logged in. + currentUser, err := session.CurrentUser(r) + if err != nil { + SendJSON(w, http.StatusForbidden, Response{ + Error: "Login Required", + }) + return + } + + // Validate the JWT token. + claims, ok, err := encryption.ValidateClaims( + token, + []byte(config.Current.SignedPhoto.JWTSecret), + &jwt.RegisteredClaims{}, + ) + if !ok || err != nil { + SendJSON(w, http.StatusForbidden, Response{ + Error: fmt.Sprintf("JWT claims: %v", err), + }) + return + } + + // Sanity check that the username in these claims is the same as the viewer. + if c, ok := claims.(*jwt.RegisteredClaims); !ok || c.Subject != currentUser.Username { + SendJSON(w, http.StatusForbidden, Response{ + Error: "That photo was not for you", + }) + return + } + + SendJSON(w, http.StatusOK, Response{ + Success: true, + Username: currentUser.Username, + }) + }) +} diff --git a/pkg/controller/chat/chat.go b/pkg/controller/chat/chat.go index d867a66..41be7ae 100644 --- a/pkg/controller/chat/chat.go +++ b/pkg/controller/chat/chat.go @@ -3,7 +3,6 @@ package chat import ( "bytes" "encoding/json" - "fmt" "io" "net/http" "sort" @@ -11,6 +10,7 @@ import ( "time" "code.nonshy.com/nonshy/website/pkg/config" + "code.nonshy.com/nonshy/website/pkg/encryption" "code.nonshy.com/nonshy/website/pkg/geoip" "code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/middleware" @@ -22,7 +22,7 @@ import ( "github.com/golang-jwt/jwt/v4" ) -// JWT claims. +// Claims are the JWT claims for the BareRTC chat room. type Claims struct { // Custom claims. IsAdmin bool `json:"op,omitempty"` @@ -123,25 +123,17 @@ func Landing() http.HandlerFunc { // Create the JWT claims. claims := Claims{ - IsAdmin: currentUser.HasAdminScope(config.ScopeChatModerator), - Avatar: avatar, - ProfileURL: "/u/" + currentUser.Username, - Nickname: currentUser.NameOrUsername(), - Emoji: emoji, - Gender: Gender(currentUser), - VIP: isShy, // "shy accounts" use the "VIP" status for special icon in chat - Rules: rules, - RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)), - IssuedAt: jwt.NewNumericDate(time.Now()), - NotBefore: jwt.NewNumericDate(time.Now()), - Issuer: config.Title, - Subject: currentUser.Username, - ID: fmt.Sprintf("%d", currentUser.ID), - }, + IsAdmin: currentUser.HasAdminScope(config.ScopeChatModerator), + Avatar: avatar, + ProfileURL: "/u/" + currentUser.Username, + Nickname: currentUser.NameOrUsername(), + Emoji: emoji, + Gender: Gender(currentUser), + VIP: isShy, // "shy accounts" use the "VIP" status for special icon in chat + Rules: rules, + RegisteredClaims: encryption.StandardClaims(currentUser.ID, currentUser.Username, 5*time.Minute), } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - ss, err := token.SignedString(secret) + token, err := encryption.SignClaims(claims, []byte(config.Current.BareRTC.JWTSecret)) if err != nil { session.FlashError(w, r, "Couldn't sign you into the chat: %s", err) templates.Redirect(w, r.URL.Path) @@ -165,7 +157,7 @@ func Landing() http.HandlerFunc { }() // Redirect them to the chat room. - templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+ss) + templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+token) return } diff --git a/pkg/encryption/jwt.go b/pkg/encryption/jwt.go new file mode 100644 index 0000000..e8d79d1 --- /dev/null +++ b/pkg/encryption/jwt.go @@ -0,0 +1,71 @@ +package encryption + +import ( + "errors" + "fmt" + "time" + + "code.nonshy.com/nonshy/website/pkg/config" + "github.com/golang-jwt/jwt/v4" +) + +// StandardClaims returns a standard JWT claim for a username. +// +// It will include values for Subject (username), Issuer (site title), ExpiresAt, IssuedAt, NotBefore. +// +// If the userID is >0, the ID field is included. +func StandardClaims(userID uint64, username string, expires time.Duration) jwt.RegisteredClaims { + claim := jwt.RegisteredClaims{ + Subject: username, + Issuer: config.Title, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(expires)), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + } + if userID > 0 { + claim.ID = fmt.Sprintf("%d", userID) + } + return claim +} + +// SignClaims creates and returns a signed JWT token. +func SignClaims(claims jwt.Claims, secret []byte) (string, error) { + // Get our Chat JWT secret. + if len(secret) == 0 { + return "", errors.New("JWT secret key is not configured") + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + ss, err := token.SignedString(secret) + if err != nil { + return "", err + } + + return ss, nil +} + +// ValidateClaims checks a JWT token is signed by the site key and returns the claims. +func ValidateClaims(tokenStr string, secret []byte, v jwt.Claims) (jwt.Claims, bool, error) { + // Handle a JWT authentication token. + var ( + claims jwt.Claims + authOK bool + ) + if tokenStr != "" { + token, err := jwt.ParseWithClaims(tokenStr, v, func(token *jwt.Token) (interface{}, error) { + return secret, nil + }) + if err != nil { + return nil, false, err + } + + if !token.Valid { + return nil, false, errors.New("token was not valid") + } + + claims = token.Claims + authOK = true + } + + return claims, authOK, nil +} diff --git a/pkg/router/router.go b/pkg/router/router.go index b036f22..54513d7 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -112,6 +112,7 @@ func New() http.Handler { // JSON API endpoints. mux.HandleFunc("GET /v1/version", api.Version()) + mux.HandleFunc("GET /v1/auth/static", api.StaticAuth()) mux.HandleFunc("GET /v1/users/me", api.LoginOK()) mux.HandleFunc("POST /v1/users/check-username", api.UsernameCheck()) mux.HandleFunc("GET /v1/web-push/vapid-public-key", webpush.VAPIDPublicKey) diff --git a/pkg/templates/template_funcs.go b/pkg/templates/template_funcs.go index 9a22860..5e12e15 100644 --- a/pkg/templates/template_funcs.go +++ b/pkg/templates/template_funcs.go @@ -12,6 +12,8 @@ import ( "time" "code.nonshy.com/nonshy/website/pkg/config" + "code.nonshy.com/nonshy/website/pkg/encryption" + "code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/markdown" "code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/photo" @@ -38,7 +40,7 @@ func TemplateFuncs(r *http.Request) template.FuncMap { "ToMarkdown": ToMarkdown, "ToJSON": ToJSON, "ToHTML": ToHTML, - "PhotoURL": photo.URLPath, + "PhotoURL": PhotoURL(r), "Now": time.Now, "RunTime": RunTime, "PrettyTitle": func() template.HTML { @@ -98,6 +100,28 @@ func RunTime(r *http.Request) string { return "ERROR" } +// PhotoURL returns a URL path to photos. +func PhotoURL(r *http.Request) func(filename string) string { + return func(filename string) string { + // Get the current user to sign a JWT token. + var token string + if currentUser, err := session.CurrentUser(r); err == nil { + claims := encryption.StandardClaims(currentUser.ID, currentUser.Username, config.SignedPhotoJWTExpires) + token, err = encryption.SignClaims(claims, []byte(config.Current.SignedPhoto.JWTSecret)) + if err != nil { + log.Error("PhotoURL: SignClaims: %s", err) + } + } + + // JWT query string to append? + if token != "" { + token = "?jwt=" + token + } + + return photo.URLPath(filename) + token + } +} + // BlurExplicit returns true if the current user has the blur_explicit setting on and the given Photo is Explicit. func BlurExplicit(r *http.Request) func(*models.Photo) bool { return func(photo *models.Photo) bool {