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:
parent
7869ff83ba
commit
cbdabe791e
|
@ -40,7 +40,9 @@ const (
|
||||||
TwoFactorBackupCodeLength = 8 // characters a-z0-9
|
TwoFactorBackupCodeLength = 8 // characters a-z0-9
|
||||||
|
|
||||||
// Signed URLs for static photo authentication.
|
// 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
|
// Authentication
|
||||||
|
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"code.nonshy.com/nonshy/website/pkg/config"
|
"code.nonshy.com/nonshy/website/pkg/config"
|
||||||
"code.nonshy.com/nonshy/website/pkg/log"
|
"code.nonshy.com/nonshy/website/pkg/log"
|
||||||
"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/session"
|
"code.nonshy.com/nonshy/website/pkg/session"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -285,7 +286,7 @@ func WhoLikes() http.HandlerFunc {
|
||||||
for _, user := range users {
|
for _, user := range users {
|
||||||
result = append(result, Liker{
|
result = append(result, Liker{
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Avatar: user.VisibleAvatarURL(currentUser),
|
Avatar: photo.VisibleAvatarURL(user, currentUser),
|
||||||
Relationship: user.UserRelationship,
|
Relationship: user.UserRelationship,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
106
pkg/controller/api/photosign_auth.go
Normal file
106
pkg/controller/api/photosign_auth.go
Normal 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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
|
@ -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,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -89,7 +89,7 @@ func Landing() http.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Avatar URL - masked if non-public.
|
// Avatar URL - masked if non-public.
|
||||||
avatar := photo.URLPath(currentUser.ProfilePhoto.CroppedFilename)
|
avatar := photo.SignedPublicAvatarURL(currentUser.ProfilePhoto.CroppedFilename)
|
||||||
switch currentUser.ProfilePhoto.Visibility {
|
switch currentUser.ProfilePhoto.Visibility {
|
||||||
case models.PhotoPrivate:
|
case models.PhotoPrivate:
|
||||||
avatar = "/static/img/shy-private.png"
|
avatar = "/static/img/shy-private.png"
|
||||||
|
|
|
@ -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.
|
// 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.
|
// Returns a boolean (false if currentUser can't see) and the Visibility setting of the profile photo.
|
||||||
|
|
84
pkg/photo/photosign.go
Normal file
84
pkg/photo/photosign.go
Normal 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
|
||||||
|
}
|
|
@ -112,7 +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/auth/static", api.PhotoSignAuth())
|
||||||
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)
|
||||||
|
|
|
@ -12,8 +12,6 @@ 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"
|
||||||
|
@ -41,6 +39,7 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
|
||||||
"ToJSON": ToJSON,
|
"ToJSON": ToJSON,
|
||||||
"ToHTML": ToHTML,
|
"ToHTML": ToHTML,
|
||||||
"PhotoURL": PhotoURL(r),
|
"PhotoURL": PhotoURL(r),
|
||||||
|
"VisibleAvatarURL": photo.VisibleAvatarURL,
|
||||||
"Now": time.Now,
|
"Now": time.Now,
|
||||||
"RunTime": RunTime,
|
"RunTime": RunTime,
|
||||||
"PrettyTitle": func() template.HTML {
|
"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.
|
// Get the current user to sign a JWT token.
|
||||||
var token string
|
var token string
|
||||||
if currentUser, err := session.CurrentUser(r); err == nil {
|
if currentUser, err := session.CurrentUser(r); err == nil {
|
||||||
claims := encryption.StandardClaims(currentUser.ID, currentUser.Username, config.SignedPhotoJWTExpires)
|
return photo.SignedPhotoURL(currentUser, filename)
|
||||||
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
|
return photo.URLPath(filename) + token
|
||||||
|
|
|
@ -9,10 +9,10 @@
|
||||||
<div class="column is-narrow has-text-centered">
|
<div class="column is-narrow has-text-centered">
|
||||||
<figure class="profile-photo is-inline-block">
|
<figure class="profile-photo is-inline-block">
|
||||||
{{if or (not .CurrentUser) .IsExternalView}}
|
{{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}}
|
{{else}}
|
||||||
<a href="/u/{{.User.Username}}/photos">
|
<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>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user