Signed and Authenticated Static Photo URLs

* Add support for authenticated static photo URLs, leveraging the NGINX module
  ngx_http_auth_request. The README is updated with an example NGINX config
  how to set this up on the proxy side.
* In settings.json a new SignedPhoto section is added: not enabled by default.
* PhotoURL will append a ?jwt= token to the /static/photos/ path for the
  current user, which expires after 30 seconds.
* When SignedPhoto is enabled, it will enforce that the JWT token is valid and
  matches the username of the current logged-in user, or else will return with
  a 403 Forbidden error.
This commit is contained in:
Noah Petherbridge 2024-10-03 21:23:12 -07:00
parent 295183559d
commit 7869ff83ba
8 changed files with 301 additions and 37 deletions

104
README.md
View File

@ -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 for local development. The production server runs on PostgreSQL and the
web app is primarily designed for that. 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 ## Building the App
This app is written in Go: [go.dev](https://go.dev). You can probably 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 For simple local development, just set `"UseSQLite": true` and the
app will run with a SQLite database. 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 ## Usage
The `nonshy` binary has sub-commands to either run the web server The `nonshy` binary has sub-commands to either run the web server

View File

@ -38,6 +38,9 @@ const (
TwoFactorBackupCodeCount = 12 TwoFactorBackupCodeCount = 12
TwoFactorBackupCodeLength = 8 // characters a-z0-9 TwoFactorBackupCodeLength = 8 // characters a-z0-9
// Signed URLs for static photo authentication.
SignedPhotoJWTExpires = 30 * time.Second
) )
// Authentication // Authentication

View File

@ -15,7 +15,7 @@ import (
// Version of the config format - when new fields are added, it will attempt // Version of the config format - when new fields are added, it will attempt
// to write the settings.toml to disk so new defaults populate. // to write the settings.toml to disk so new defaults populate.
var currentVersion = 4 var currentVersion = 5
// Current loaded settings.json // Current loaded settings.json
var Current = DefaultVariable() var Current = DefaultVariable()
@ -32,6 +32,7 @@ type Variable struct {
BareRTC BareRTC BareRTC BareRTC
Maintenance Maintenance Maintenance Maintenance
Encryption Encryption Encryption Encryption
SignedPhoto SignedPhoto
WebPush WebPush WebPush WebPush
Turnstile Turnstile Turnstile Turnstile
UseXForwardedFor bool UseXForwardedFor bool
@ -126,6 +127,12 @@ func LoadSettings() {
writeSettings = true 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. // Have we added new config fields? Save the settings.json.
if Current.Version != currentVersion || writeSettings { if Current.Version != currentVersion || writeSettings {
log.Warn("New options are available for your settings.json file. Your settings will be re-saved now.") 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 ColdStorageRSAPublicKey []byte
} }
// SignedPhoto settings.
type SignedPhoto struct {
Enabled bool
JWTSecret string
}
// WebPush settings. // WebPush settings.
type WebPush struct { type WebPush struct {
VAPIDPublicKey string VAPIDPublicKey string

View File

@ -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,
})
})
}

View File

@ -3,7 +3,6 @@ package chat
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"net/http" "net/http"
"sort" "sort"
@ -11,6 +10,7 @@ import (
"time" "time"
"code.nonshy.com/nonshy/website/pkg/config" "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/geoip"
"code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/middleware" "code.nonshy.com/nonshy/website/pkg/middleware"
@ -22,7 +22,7 @@ import (
"github.com/golang-jwt/jwt/v4" "github.com/golang-jwt/jwt/v4"
) )
// JWT claims. // Claims are the JWT claims for the BareRTC chat room.
type Claims struct { type Claims struct {
// Custom claims. // Custom claims.
IsAdmin bool `json:"op,omitempty"` IsAdmin bool `json:"op,omitempty"`
@ -123,25 +123,17 @@ func Landing() http.HandlerFunc {
// Create the JWT claims. // Create the JWT claims.
claims := Claims{ claims := Claims{
IsAdmin: currentUser.HasAdminScope(config.ScopeChatModerator), IsAdmin: currentUser.HasAdminScope(config.ScopeChatModerator),
Avatar: avatar, Avatar: avatar,
ProfileURL: "/u/" + currentUser.Username, ProfileURL: "/u/" + currentUser.Username,
Nickname: currentUser.NameOrUsername(), Nickname: currentUser.NameOrUsername(),
Emoji: emoji, Emoji: emoji,
Gender: Gender(currentUser), Gender: Gender(currentUser),
VIP: isShy, // "shy accounts" use the "VIP" status for special icon in chat VIP: isShy, // "shy accounts" use the "VIP" status for special icon in chat
Rules: rules, Rules: rules,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: encryption.StandardClaims(currentUser.ID, currentUser.Username, 5*time.Minute),
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),
},
} }
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) token, err := encryption.SignClaims(claims, []byte(config.Current.BareRTC.JWTSecret))
ss, err := token.SignedString(secret)
if err != nil { if err != nil {
session.FlashError(w, r, "Couldn't sign you into the chat: %s", err) session.FlashError(w, r, "Couldn't sign you into the chat: %s", err)
templates.Redirect(w, r.URL.Path) templates.Redirect(w, r.URL.Path)
@ -165,7 +157,7 @@ func Landing() http.HandlerFunc {
}() }()
// Redirect them to the chat room. // Redirect them to the chat room.
templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+ss) templates.Redirect(w, strings.TrimSuffix(chatURL, "/")+"/?jwt="+token)
return return
} }

71
pkg/encryption/jwt.go Normal file
View File

@ -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
}

View File

@ -112,6 +112,7 @@ func New() http.Handler {
// JSON API endpoints. // JSON API endpoints.
mux.HandleFunc("GET /v1/version", api.Version()) 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("GET /v1/users/me", api.LoginOK())
mux.HandleFunc("POST /v1/users/check-username", api.UsernameCheck()) mux.HandleFunc("POST /v1/users/check-username", api.UsernameCheck())
mux.HandleFunc("GET /v1/web-push/vapid-public-key", webpush.VAPIDPublicKey) mux.HandleFunc("GET /v1/web-push/vapid-public-key", webpush.VAPIDPublicKey)

View File

@ -12,6 +12,8 @@ import (
"time" "time"
"code.nonshy.com/nonshy/website/pkg/config" "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/markdown"
"code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/photo" "code.nonshy.com/nonshy/website/pkg/photo"
@ -38,7 +40,7 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
"ToMarkdown": ToMarkdown, "ToMarkdown": ToMarkdown,
"ToJSON": ToJSON, "ToJSON": ToJSON,
"ToHTML": ToHTML, "ToHTML": ToHTML,
"PhotoURL": photo.URLPath, "PhotoURL": PhotoURL(r),
"Now": time.Now, "Now": time.Now,
"RunTime": RunTime, "RunTime": RunTime,
"PrettyTitle": func() template.HTML { "PrettyTitle": func() template.HTML {
@ -98,6 +100,28 @@ func RunTime(r *http.Request) string {
return "ERROR" 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. // 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 { func BlurExplicit(r *http.Request) func(*models.Photo) bool {
return func(photo *models.Photo) bool { return func(photo *models.Photo) bool {