From cbdabe791ef56b0645968882671f9b354863f399 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Thu, 3 Oct 2024 22:08:19 -0700 Subject: [PATCH] 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) --- pkg/config/config.go | 4 +- pkg/controller/api/likes.go | 3 +- pkg/controller/api/photosign_auth.go | 106 +++++++++++++++++++++++++++ pkg/controller/api/static_auth.go | 84 --------------------- pkg/controller/chat/chat.go | 2 +- pkg/models/user.go | 22 ------ pkg/photo/photosign.go | 84 +++++++++++++++++++++ pkg/router/router.go | 2 +- pkg/templates/template_funcs.go | 14 +--- web/templates/account/profile.html | 4 +- 10 files changed, 201 insertions(+), 124 deletions(-) create mode 100644 pkg/controller/api/photosign_auth.go delete mode 100644 pkg/controller/api/static_auth.go create mode 100644 pkg/photo/photosign.go diff --git a/pkg/config/config.go b/pkg/config/config.go index 7e42110..edb7fb3 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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 diff --git a/pkg/controller/api/likes.go b/pkg/controller/api/likes.go index 4243909..00f49b0 100644 --- a/pkg/controller/api/likes.go +++ b/pkg/controller/api/likes.go @@ -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, }) } diff --git a/pkg/controller/api/photosign_auth.go b/pkg/controller/api/photosign_auth.go new file mode 100644 index 0000000..1c19da9 --- /dev/null +++ b/pkg/controller/api/photosign_auth.go @@ -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, + }) + }) +} diff --git a/pkg/controller/api/static_auth.go b/pkg/controller/api/static_auth.go deleted file mode 100644 index 9217ad7..0000000 --- a/pkg/controller/api/static_auth.go +++ /dev/null @@ -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, - }) - }) -} diff --git a/pkg/controller/chat/chat.go b/pkg/controller/chat/chat.go index 41be7ae..8c54c93 100644 --- a/pkg/controller/chat/chat.go +++ b/pkg/controller/chat/chat.go @@ -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" diff --git a/pkg/models/user.go b/pkg/models/user.go index d1b75c5..7f4bf2f 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -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. diff --git a/pkg/photo/photosign.go b/pkg/photo/photosign.go new file mode 100644 index 0000000..dbbb6c0 --- /dev/null +++ b/pkg/photo/photosign.go @@ -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 +} diff --git a/pkg/router/router.go b/pkg/router/router.go index 54513d7..e39b4e5 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -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) diff --git a/pkg/templates/template_funcs.go b/pkg/templates/template_funcs.go index 5e12e15..fa2eb0b 100644 --- a/pkg/templates/template_funcs.go +++ b/pkg/templates/template_funcs.go @@ -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 diff --git a/web/templates/account/profile.html b/web/templates/account/profile.html index 4b2ffed..b306f6f 100644 --- a/web/templates/account/profile.html +++ b/web/templates/account/profile.html @@ -9,10 +9,10 @@
{{if or (not .CurrentUser) .IsExternalView}} - + {{else}} - + {{end}}