Improve Signed Photo URLs

* The photo signing JWT tokens carry more fields to validate against:
  * The username the token is assigned to (or '@' for anyone)
  * An 'anyone' boolean for widely public images, such as for the chat room
    and public profile pages.
  * A short filename hash of the image in question (whether a Photo or a
    CommentPhoto) - so that the user can't borrow a JWT token from the chat
    room and reveal a different picture.
* Refactored where the VisibleAvatarURL function lives, to avoid a cyclic
  dependency error.
  * Originally: (*models.User).VisibleAvatarURL(other *models.User)
  * Now: (pkg/photo).VisibleAvatarURL(user, currentUser *models.User)
This commit is contained in:
Noah Petherbridge 2024-10-03 22:08:19 -07:00
parent 7869ff83ba
commit cbdabe791e
10 changed files with 201 additions and 124 deletions

View File

@ -40,7 +40,9 @@ const (
TwoFactorBackupCodeLength = 8 // characters a-z0-9
// Signed URLs for static photo authentication.
SignedPhotoJWTExpires = 30 * time.Second
SignedPhotoJWTExpires = 30 * time.Second // Regular, per-user, short window
SignedPublicAvatarJWTExpires = 7 * 24 * time.Hour // Widely public, e.g. chat room
SignedPublicAvatarUsername = "@" // JWT 'username' for widely public JWT
)
// Authentication

View File

@ -8,6 +8,7 @@ import (
"code.nonshy.com/nonshy/website/pkg/config"
"code.nonshy.com/nonshy/website/pkg/log"
"code.nonshy.com/nonshy/website/pkg/models"
"code.nonshy.com/nonshy/website/pkg/photo"
"code.nonshy.com/nonshy/website/pkg/session"
)
@ -285,7 +286,7 @@ func WhoLikes() http.HandlerFunc {
for _, user := range users {
result = append(result, Liker{
Username: user.Username,
Avatar: user.VisibleAvatarURL(currentUser),
Avatar: photo.VisibleAvatarURL(user, currentUser),
Relationship: user.UserRelationship,
})
}

View File

@ -0,0 +1,106 @@
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/log"
"code.nonshy.com/nonshy/website/pkg/photo"
"code.nonshy.com/nonshy/website/pkg/session"
)
// PhotoSignAuth API protects paths like /static/photos/ to authenticated user requests only.
func PhotoSignAuth() http.HandlerFunc {
type Response struct {
Success bool `json:"success"`
Error string `json:",omitempty"`
Username string `json:"username"`
}
logAndError := func(w http.ResponseWriter, m string, v ...interface{}) {
log.Debug("ERROR PhotoSignAuth: "+m, v...)
SendJSON(w, http.StatusForbidden, Response{
Error: fmt.Sprintf(m, v...),
})
}
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
}
// Get the base filename.
var filename = strings.TrimPrefix(
strings.SplitN(originalURI, config.PhotoWebPath, 2)[1],
"/",
)
filename = strings.SplitN(filename, "?", 2)[0] // inner query string too
// 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 JWT token is required from here on out.
if token == "" {
logAndError(w, "JWT token is required")
return
}
// Check if we're logged in and who the current username is.
var username string
if currentUser, err := session.CurrentUser(r); err == nil {
username = currentUser.Username
}
// Validate the JWT token is correctly signed and not expired.
claims, ok, err := encryption.ValidateClaims(
token,
[]byte(config.Current.SignedPhoto.JWTSecret),
&photo.SignedPhotoClaims{},
)
if !ok || err != nil {
logAndError(w, "When validating JWT claims: %v", err)
return
}
// Parse the claims to get data to validate this request.
c, ok := claims.(*photo.SignedPhotoClaims)
if !ok {
logAndError(w, "JWT claims were not the correct shape: %+v", claims)
return
}
// Was the signature for our username? (Skip if for Anyone)
if !c.Anyone && c.Subject != username {
logAndError(w, "That token did not belong to you")
return
}
// Is the file name correct?
hash := photo.FilenameHash(filename)
if hash != c.FilenameHash {
logAndError(w, "Filename hash mismatch: fn=%s hash=%s jwt=%s", filename, hash, c.FilenameHash)
return
}
log.Debug("PhotoSignAuth: JWT Signature OK! fn=%s u=%s anyone=%v expires=%+v", filename, c.Subject, c.Anyone, c.ExpiresAt)
SendJSON(w, http.StatusOK, Response{
Success: true,
Username: username,
})
})
}

View File

@ -1,84 +0,0 @@
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

@ -89,7 +89,7 @@ func Landing() http.HandlerFunc {
}
// Avatar URL - masked if non-public.
avatar := photo.URLPath(currentUser.ProfilePhoto.CroppedFilename)
avatar := photo.SignedPublicAvatarURL(currentUser.ProfilePhoto.CroppedFilename)
switch currentUser.ProfilePhoto.Visibility {
case models.PhotoPrivate:
avatar = "/static/img/shy-private.png"

View File

@ -701,28 +701,6 @@ func (u *User) NameOrUsername() string {
}
}
// VisibleAvatarURL returns a URL to the user's avatar taking into account
// their relationship with the current user. For example, if the avatar is
// friends-only and the current user can't see it, returns the path to the
// yellow placeholder avatar instead.
//
// Expects that UserRelationships are available on the user.
func (u *User) VisibleAvatarURL(currentUser *User) string {
canSee, visibility := u.CanSeeProfilePicture(currentUser)
if canSee {
return config.PhotoWebPath + "/" + u.ProfilePhoto.CroppedFilename
}
switch visibility {
case PhotoPrivate:
return "/static/img/shy-private.png"
case PhotoFriends:
return "/static/img/shy-friends.png"
}
return "/static/img/shy.png"
}
// CanSeeProfilePicture returns whether the current user can see the user's profile picture.
//
// Returns a boolean (false if currentUser can't see) and the Visibility setting of the profile photo.

84
pkg/photo/photosign.go Normal file
View File

@ -0,0 +1,84 @@
package photo
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/models"
"github.com/golang-jwt/jwt/v4"
)
// VisibleAvatarURL returns the visible URL image to a user's square profile picture, from the point of view of the currentUser.
func VisibleAvatarURL(user, currentUser *models.User) string {
canSee, visibility := user.CanSeeProfilePicture(currentUser)
if canSee {
return SignedPublicAvatarURL(user.ProfilePhoto.CroppedFilename)
}
switch visibility {
case models.PhotoPrivate:
return "/static/img/shy-private.png"
case models.PhotoFriends:
return "/static/img/shy-friends.png"
}
return "/static/img/shy.png"
}
// SignedPhotoURL returns a URL path to a photo's filename, signed for the current user only.
func SignedPhotoURL(user *models.User, filename string) string {
return createSignedPhotoURL(user.ID, user.Username, filename, config.SignedPhotoJWTExpires, false)
}
// SignedPublicAvatarURL returns a signed URL for a user's public square avatar image, which has
// a much more generous JWT expiration lifetime on it.
//
// The primary use case is for the chat room: users are sent into chat with their avatar URL,
// and it must be viewable to all users for a long time.
func SignedPublicAvatarURL(filename string) string {
return createSignedPhotoURL(0, "@", filename, config.SignedPublicAvatarJWTExpires, true)
}
// SignedPhotoClaims are a JWT claims object used to sign and authenticate image (direct .jpg) links.
type SignedPhotoClaims struct {
FilenameHash string `json:"f"` // Short hash of the Filename being signed.
Anyone bool `json:"a,omitempty"` // Non-authenticated signature (e.g. public sq avatar URLs)
// Standard claims. Notes:
// .Subject = username
jwt.RegisteredClaims
}
// FilenameHash returns a 'short' hash of the filename, for encoding in the SignedPhotoClaims.
//
// The hash is a truncated SHA256 hash as a basic validation measure against one JWT token being
// used to reveal an unrelated picture.
func FilenameHash(filename string) string {
return encryption.Hash([]byte(filename))[:6]
}
// Common function to create a signed photo URL with an expiration.
func createSignedPhotoURL(userID uint64, username string, filename string, expires time.Duration, anyone bool) string {
claims := SignedPhotoClaims{
FilenameHash: FilenameHash(filename),
Anyone: anyone,
RegisteredClaims: encryption.StandardClaims(userID, username, expires),
}
log.Debug("createSignedPhotoURL(%s): %+v", filename, claims)
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 URLPath(filename) + token
}

View File

@ -112,7 +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/auth/static", api.PhotoSignAuth())
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)

View File

@ -12,8 +12,6 @@ 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"
@ -41,6 +39,7 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
"ToJSON": ToJSON,
"ToHTML": ToHTML,
"PhotoURL": PhotoURL(r),
"VisibleAvatarURL": photo.VisibleAvatarURL,
"Now": time.Now,
"RunTime": RunTime,
"PrettyTitle": func() template.HTML {
@ -106,16 +105,7 @@ func PhotoURL(r *http.Request) 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.SignedPhotoURL(currentUser, filename)
}
return photo.URLPath(filename) + token

View File

@ -9,10 +9,10 @@
<div class="column is-narrow has-text-centered">
<figure class="profile-photo is-inline-block">
{{if or (not .CurrentUser) .IsExternalView}}
<img src="{{.User.VisibleAvatarURL nil}}" data-photo-id="{{.User.ProfilePhoto.ID}}">
<img src="{{VisibleAvatarURL .User nil}}" data-photo-id="{{.User.ProfilePhoto.ID}}">
{{else}}
<a href="/u/{{.User.Username}}/photos">
<img src="{{.User.VisibleAvatarURL .CurrentUser}}" data-photo-id="{{.User.ProfilePhoto.ID}}">
<img src="{{VisibleAvatarURL .User .CurrentUser}}" data-photo-id="{{.User.ProfilePhoto.ID}}">
</a>
{{end}}