Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

auth: add ability to authenticate downloads with JWT tokens #5

Merged
merged 2 commits into from
Feb 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
module github.com/matrix-org/rageshake

go 1.19
go 1.22

require gopkg.in/yaml.v2 v2.2.2
require (
github.com/golang-jwt/jwt/v5 v5.2.0
gopkg.in/yaml.v2 v2.2.2
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
Expand Down
77 changes: 57 additions & 20 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"os"
"strings"

"github.com/golang-jwt/jwt/v5"
"gopkg.in/yaml.v2"
)

Expand All @@ -34,8 +35,9 @@ var bindAddr = flag.String("listen", ":9110", "The port to listen on.")

type config struct {
// Username and password required to access the bug report listings
BugsUser string `yaml:"listings_auth_user"`
BugsPass string `yaml:"listings_auth_pass"`
BugsUser string `yaml:"listings_auth_user"`
BugsPass string `yaml:"listings_auth_pass"`
BugsJWTSecret string `yaml:"listings_jwt_secret"`

// External URI to /api
APIPrefix string `yaml:"api_prefix"`
Expand All @@ -47,15 +49,59 @@ type config struct {
WebhookURL string `yaml:"webhook_url"`
}

func basicAuth(handler http.Handler, username, password, realm string) http.Handler {
const (
rageshakeIssuer = "com.beeper.rageshake"
apiServerIssuer = "com.beeper.api-server"
)

func basicAuthOrJWTAuthenticated(handler http.Handler, username, password, realm string, jwtSecret []byte) http.Handler {
if (username == "" || password == "") && len(jwtSecret) == 0 {
panic("Either username or password for basic auth must be set, or JWT secret must be set, or both")
}

unauthorized := func(w http.ResponseWriter) {
w.WriteHeader(401)
w.Write([]byte("Unauthorised.\n"))
}

return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth() // pull creds from the request

// check user and pass securely
if !ok || subtle.ConstantTimeCompare([]byte(user), []byte(username)) != 1 || subtle.ConstantTimeCompare([]byte(pass), []byte(password)) != 1 {
if !ok && len(jwtSecret) == 0 { // if no basic auth and no JWT auth, return unauthorized
unauthorized(w)
return
} else if !ok { // if no basic auth, try to do JWT auth
token, err := jwt.ParseWithClaims(r.URL.Query().Get("tok"), &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) {
// Don't forget to validate the alg is what you expect:
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return jwtSecret, nil
})
if err != nil {
log.Printf("Error parsing JWT: %v", err)
unauthorized(w)
return
}

claims, ok := token.Claims.(*jwt.RegisteredClaims)
if !token.Valid || !ok {
log.Printf("Token invalid or claims not RegisteredClaims: %v", err)
unauthorized(w)
return
} else if claims.Issuer != rageshakeIssuer && claims.Issuer != apiServerIssuer {
log.Printf("Token issuer not rageshake or API server: %s", claims.Issuer)
unauthorized(w)
return
} else if claims.Subject != r.URL.Path {
log.Printf("Token subject (%s) not the request path (%s)", claims.Subject, r.URL.Path)
unauthorized(w)
return
}

log.Printf("Valid token from %s for accessing %s", claims.Issuer, claims.Subject)
} else if subtle.ConstantTimeCompare([]byte(user), []byte(username)) != 1 || subtle.ConstantTimeCompare([]byte(pass), []byte(password)) != 1 { // check user and pass securely
w.Header().Set("WWW-Authenticate", `Basic realm="`+realm+`"`)
w.WriteHeader(401)
w.Write([]byte("Unauthorised.\n"))
unauthorized(w)
return
}

Expand Down Expand Up @@ -99,19 +145,10 @@ func main() {

// serve files under "bugs"
ls := &logServer{"bugs"}
fs := http.StripPrefix("/api/listing/", ls)

// set auth if env vars exist
usr := cfg.BugsUser
pass := cfg.BugsPass
if usr == "" || pass == "" {
fmt.Println("No listings_auth_user/pass configured. No authentication is running for /api/listing")
} else {
fs = basicAuth(fs, usr, pass, "Riot bug reports")
}
http.Handle("/api/listing/", fs)
fs := basicAuthOrJWTAuthenticated(ls, cfg.BugsUser, cfg.BugsPass, "Riot bug reports", []byte(cfg.BugsJWTSecret))
http.Handle("/api/listing/", http.StripPrefix("/api/listing/", fs))

http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
http.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, "ok")
})

Expand Down
1 change: 1 addition & 0 deletions rageshake.sample.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# *no* authentication on this access!
listings_auth_user: alice
listings_auth_pass: secret
listings_jwt_secret: secret

# the external URL at which /api is accessible; it is used to add a link to the
# report to the GitHub issue. If unspecified, based on the listen address.
Expand Down
31 changes: 21 additions & 10 deletions submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import (
"strconv"
"strings"
"time"

"github.com/golang-jwt/jwt/v5"
)

var maxPayloadSize = 1024 * 1024 * 55 // 55 MB
Expand Down Expand Up @@ -659,7 +661,7 @@ func saveLogPart(logNum int, filename string, reader io.Reader, reportDir string
return leafName, nil
}

func (s *submitServer) saveReportBackground(p parsedPayload, reportDir, listingURL string) error {
func (s *submitServer) saveReportBackground(p parsedPayload, listingURL string) error {
var resp submitResponse

if err := s.submitLinearIssue(p, listingURL, &resp); err != nil {
Expand All @@ -681,7 +683,7 @@ func (s *submitServer) saveReport(p parsedPayload, reportDir, listingURL string)
}

go func() {
err := s.saveReportBackground(p, reportDir, listingURL)
err := s.saveReportBackground(p, listingURL)
if err != nil {
fmt.Println("Error submitting report in background:", err)
}
Expand Down Expand Up @@ -868,6 +870,16 @@ func buildReportTitle(p parsedPayload) string {
return title
}

func (s *submitServer) createToken(path string) (string, error) {
claims := &jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)),
Issuer: rageshakeIssuer,
Subject: path,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(s.cfg.BugsJWTSecret))
}

func (s *submitServer) buildReportBody(p parsedPayload, listingURL string) *bytes.Buffer {
var bodyBuf bytes.Buffer

Expand All @@ -887,19 +899,18 @@ func (s *submitServer) buildReportBody(p parsedPayload, listingURL string) *byte

fmt.Fprintf(&bodyBuf, "### User message:\n\n%s\n\n", userText)

var authedListingURL string
if len(p.Files) > 0 {
parsed, _ := url.Parse(listingURL)
parsed.User = url.UserPassword(s.cfg.BugsUser, s.cfg.BugsPass)
authedListingURL = parsed.String()
}
for _, file := range p.Files {
imageifier := ""
fileURL := listingURL + "/" + file
ext := strings.ToLower(filepath.Ext(file))
if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" {
imageifier = "!"
fileURL = authedListingURL + "/" + file
jwtTok, err := s.createToken(strings.TrimPrefix(fileURL, s.apiPrefix+"/listing/"))
if err != nil {
log.Printf("Error creating token for image URL: %v", err)
} else {
imageifier = "!"
fileURL = fileURL + "?tok=" + jwtTok
}
}
fmt.Fprintf(
&bodyBuf,
Expand Down
Loading