website/pkg/controller/api/photosign_auth.go
Noah Petherbridge cbdabe791e 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)
2024-10-03 20:14:34 -07:00

107 lines
2.9 KiB
Go

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,
})
})
}