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
|
||||
|
||||
// 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
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
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 := photo.URLPath(currentUser.ProfilePhoto.CroppedFilename)
|
||||
avatar := photo.SignedPublicAvatarURL(currentUser.ProfilePhoto.CroppedFilename)
|
||||
switch currentUser.ProfilePhoto.Visibility {
|
||||
case models.PhotoPrivate:
|
||||
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.
|
||||
//
|
||||
// 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.
|
||||
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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}}
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user