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:
parent
295183559d
commit
7869ff83ba
104
README.md
104
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
|
||||
|
|
|
@ -38,6 +38,9 @@ const (
|
|||
|
||||
TwoFactorBackupCodeCount = 12
|
||||
TwoFactorBackupCodeLength = 8 // characters a-z0-9
|
||||
|
||||
// Signed URLs for static photo authentication.
|
||||
SignedPhotoJWTExpires = 30 * time.Second
|
||||
)
|
||||
|
||||
// Authentication
|
||||
|
|
|
@ -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
|
||||
|
|
84
pkg/controller/api/static_auth.go
Normal file
84
pkg/controller/api/static_auth.go
Normal 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,
|
||||
})
|
||||
})
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
71
pkg/encryption/jwt.go
Normal file
71
pkg/encryption/jwt.go
Normal 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
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in New Issue
Block a user