diff --git a/flake.nix b/flake.nix index 3a47cdf..8bbabb8 100644 --- a/flake.nix +++ b/flake.nix @@ -5,21 +5,34 @@ flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { self, nixpkgs, flake-utils }: - (flake-utils.lib.eachDefaultSystem (system: - let pkgs = import nixpkgs { inherit system; }; - in rec { + outputs = + { + self, + nixpkgs, + flake-utils, + }: + (flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { inherit system; }; + in + rec { packages.mineshspc = pkgs.buildGoModule { pname = "mineshspc.com"; version = "unstable-2026-01-11"; src = self; subPackages = [ "cmd/mineshspc" ]; - vendorHash = "sha256-q4AXwm8QRedjkO3565GASsCIxnmhzMGAYHEr6p0Es+0="; + vendorHash = "sha256-ylJWr0OFXY15APTq+ozVbFkAQ1lreM/9kZhJ6qYhx8E="; }; packages.default = packages.mineshspc; devShells.default = pkgs.mkShell { - packages = with pkgs; [ go gotools pre-commit ]; + packages = with pkgs; [ + go + gotools + pre-commit + ]; }; - })); + } + )); } diff --git a/go.mod b/go.mod index 2e2ea3d..786a154 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/rs/zerolog v1.35.0 github.com/sendgrid/sendgrid-go v3.16.1+incompatible github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + github.com/stretchr/testify v1.11.1 go.mau.fi/util v0.9.7 go.mau.fi/zeroconfig v0.2.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/internal/admin.go b/internal/admin.go index eb20406..03214bc 100644 --- a/internal/admin.go +++ b/internal/admin.go @@ -3,7 +3,6 @@ package internal import ( "context" "encoding/csv" - "errors" "fmt" "net/http" "time" @@ -24,47 +23,7 @@ func (a *Application) createAdminLoginJWT(email string) *jwt.Token { }) } -func (a *Application) isAdminByToken(tokenStr string) (bool, error) { - if tokenStr == "" { - return false, errors.New("no token") - } - - token, err := jwt.ParseWithClaims(tokenStr, &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 a.Config.ReadSecretKey(), nil - }) - if err != nil { - return false, fmt.Errorf("failed to parse admin token: %w", err) - } - - claims, ok := token.Claims.(*jwt.RegisteredClaims) - if !token.Valid || !ok { - return false, fmt.Errorf("failed to validate token: %w", err) - } - - if claims.Issuer != string(IssuerAdminLogin) { - return false, fmt.Errorf("invalid student verify token: %w", err) - } - - return true, nil -} - func (a *Application) GetAdminTeamsTemplate(r *http.Request) map[string]any { - tok, err := r.Cookie("admin_token") - if err != nil { - a.Log.Warn().Err(err).Msg("failed to get admin token") - return nil - } - - if isAdmin, err := a.isAdminByToken(tok.Value); err != nil || !isAdmin { - a.Log.Warn().Err(err).Msg("user is not admin!") - return nil - } - teamsWithTeachers, err := a.DB.GetAdminTeamsWithTeacherName(r.Context()) if err != nil { a.Log.Err(err).Msg("failed to get teams") @@ -153,17 +112,6 @@ func (a *Application) GetAdminTeamsTemplate(r *http.Request) map[string]any { } func (a *Application) GetAdminDietaryRestrictionsTemplate(r *http.Request) map[string]any { - tok, err := r.Cookie("admin_token") - if err != nil { - a.Log.Warn().Err(err).Msg("failed to get admin token") - return nil - } - - if isAdmin, err := a.isAdminByToken(tok.Value); err != nil || !isAdmin { - a.Log.Warn().Err(err).Msg("user is not admin!") - return nil - } - dietaryRestrictions, err := a.DB.GetAllDietaryRestrictions(r.Context()) if err != nil { a.Log.Err(err).Msg("failed to get dietary restrictions") @@ -179,7 +127,7 @@ func (a *Application) HandleAdminEmailLogin(w http.ResponseWriter, r *http.Reque tok := r.URL.Query().Get("tok") log := zerolog.Ctx(r.Context()) log.Info().Str("token", tok).Msg("got token") - isAdmin, err := a.isAdminByToken(tok) + isAdmin, err := a.parseTokenByIssuer(tok, IssuerAdminLogin) if err != nil || !isAdmin { log.Warn().Msg("failed to get admin") w.WriteHeader(http.StatusBadRequest) diff --git a/internal/application.go b/internal/application.go index 2a03bb6..607a3f3 100644 --- a/internal/application.go +++ b/internal/application.go @@ -3,6 +3,7 @@ package internal import ( "fmt" "html/template" + "maps" "net/http" "regexp" "strings" @@ -66,9 +67,7 @@ func (a *Application) ServeTemplateExtra(logger *zerolog.Logger, templateName st if data == nil { data = map[string]any{} } - for k, v := range extraData { - data[k] = v - } + maps.Copy(data, extraData) user, err := a.GetLoggedInTeacher(r) if err == nil { data["Username"] = user.Name @@ -91,12 +90,9 @@ type renderInfo struct { RedirectIfLoggedIn bool } -func (a *Application) Start() { - a.Log.Info().Msg("connecting to sendgrid") - a.SendGridClient = sendgrid.NewSendClient(a.Config.SendgridAPIKey) - - a.Log.Info().Msg("Starting router") - +// BuildRouter sets up all HTTP routes and returns the handler. Separated from +// Start so tests can exercise routing without starting a real server. +func (a *Application) BuildRouter() http.Handler { router := http.NewServeMux() noArgs := func(r *http.Request) map[string]any { return nil } @@ -172,6 +168,10 @@ func (a *Application) Start() { a.Log.Info().Err(err). Bool("redirect_if_logged_in", rend.RedirectIfLoggedIn). Msg("Failed to get logged in teacher") + if !rend.RedirectIfLoggedIn && strings.HasPrefix(path, "/register/teacher/") { + http.Redirect(w, r, "/register/teacher/login", http.StatusSeeOther) + return + } if rend.RedirectIfLoggedIn && path != "/register/teacher/login" && path != "/register/teacher/createaccount" && path != "/register/teacher/confirmemail" { http.Redirect(w, r, "/register/teacher/login", http.StatusTemporaryRedirect) } @@ -217,41 +217,58 @@ func (a *Application) Start() { router.HandleFunc("POST "+path, renderFn(fn)) } - // Admin pages - router.HandleFunc("GET /admin", a.ServeTemplate(a.Log, "adminhome.html", noArgs)) + // Admin pages (unprotected — these exact patterns beat the /admin/ prefix below) router.HandleFunc("GET /admin/login", a.ServeTemplate(a.Log, "adminlogin.html", noArgs)) - router.HandleFunc("GET /admin/dietaryrestrictions", a.ServeTemplate(a.Log, "admindietaryrestrictions.html", a.GetAdminDietaryRestrictionsTemplate)) - router.HandleFunc("GET /admin/teams", a.ServeTemplate(a.Log, "adminteams.html", a.GetAdminTeamsTemplate)) router.HandleFunc("GET /admin/emaillogin", a.HandleAdminEmailLogin) router.HandleFunc("POST /admin/emaillogin", a.HandleAdminLogin) - adminAPIRouter := http.NewServeMux() - adminAPIRouter.HandleFunc("GET /resendstudentemail", a.HandleResendStudentEmail) - adminAPIRouter.HandleFunc("GET /resendparentemail", a.HandleResendParentEmail) - adminAPIRouter.HandleFunc("GET /confirmationlink/student", a.HandleGetStudentEmailConfirmationLink) - adminAPIRouter.HandleFunc("GET /confirmationlink/parent", a.HandleGetParentEmailConfirmationLink) - adminAPIRouter.HandleFunc("GET /sendemailconfirmationreminders", a.HandleSendEmailConfirmationReminders) - adminAPIRouter.HandleFunc("GET /sendparentreminders", a.HandleSendParentReminders) - adminAPIRouter.HandleFunc("GET /sendqrcodes", a.HandleSendQRCodes) - adminAPIRouter.HandleFunc("GET /kattis/teams", a.HandleKattisTeamsExport) - adminAPIRouter.HandleFunc("GET /kattis/participants", a.HandleKattisParticipantsExport) - adminAPIRouter.HandleFunc("GET /zoom/breakout", a.HandleZoomBreakoutExport) - adminAPIRouter.HandleFunc("GET /manualcheckin", a.HandleManualCheckin) - adminAPIRouter.HandleFunc("GET /team-list", a.HandleTeamList) - router.Handle("/admin/api/", http.StripPrefix("/admin/api", a.AdminAuthMiddleware(adminAPIRouter))) - - // Volunteer pages + // Admin pages (protected) — subrouter consolidates all protected admin routes + adminRouter := http.NewServeMux() + adminRouter.HandleFunc("GET /{$}", a.ServeTemplate(a.Log, "adminhome.html", noArgs)) + adminRouter.HandleFunc("GET /dietaryrestrictions", a.ServeTemplate(a.Log, "admindietaryrestrictions.html", a.GetAdminDietaryRestrictionsTemplate)) + adminRouter.HandleFunc("GET /teams", a.ServeTemplate(a.Log, "adminteams.html", a.GetAdminTeamsTemplate)) + adminRouter.HandleFunc("GET /api/resendstudentemail", a.HandleResendStudentEmail) + adminRouter.HandleFunc("GET /api/resendparentemail", a.HandleResendParentEmail) + adminRouter.HandleFunc("GET /api/confirmationlink/student", a.HandleGetStudentEmailConfirmationLink) + adminRouter.HandleFunc("GET /api/confirmationlink/parent", a.HandleGetParentEmailConfirmationLink) + adminRouter.HandleFunc("GET /api/sendemailconfirmationreminders", a.HandleSendEmailConfirmationReminders) + adminRouter.HandleFunc("GET /api/sendparentreminders", a.HandleSendParentReminders) + adminRouter.HandleFunc("GET /api/sendqrcodes", a.HandleSendQRCodes) + adminRouter.HandleFunc("GET /api/kattis/teams", a.HandleKattisTeamsExport) + adminRouter.HandleFunc("GET /api/kattis/participants", a.HandleKattisParticipantsExport) + adminRouter.HandleFunc("GET /api/zoom/breakout", a.HandleZoomBreakoutExport) + adminRouter.HandleFunc("GET /api/manualcheckin", a.HandleManualCheckin) + adminRouter.HandleFunc("GET /api/team-list", a.HandleTeamList) + router.Handle("/admin/", http.StripPrefix("/admin", a.AdminAuthMiddleware(adminRouter))) + router.Handle("GET /admin", a.AdminAuthMiddleware( + http.HandlerFunc(a.ServeTemplate(a.Log, "adminhome.html", noArgs)))) + + // Volunteer pages (unprotected) router.HandleFunc("GET /volunteer", a.ServeTemplate(a.Log, "volunteerhome.html", noArgs)) router.HandleFunc("GET /volunteer/login", a.ServeTemplate(a.Log, "volunteerlogin.html", noArgs)) router.HandleFunc("GET /volunteer/emaillogin", a.HandleVolunteerEmailLogin) router.HandleFunc("POST /volunteer/emaillogin", a.HandleVolunteerLogin) - router.HandleFunc("GET /volunteer/scan", a.ServeTemplate(a.Log, "volunteerscan.html", a.GetVolunteerScanTemplate)) - router.HandleFunc("GET /volunteer/checkin", a.HandleVolunteerCheckIn) + + // Volunteer pages (protected) + router.Handle("GET /volunteer/scan", a.VolunteerAuthMiddleware( + http.HandlerFunc(a.ServeTemplate(a.Log, "volunteerscan.html", a.GetVolunteerScanTemplate)))) + router.Handle("GET /volunteer/checkin", a.VolunteerAuthMiddleware( + http.HandlerFunc(a.HandleVolunteerCheckIn))) var handler http.Handler = router handler = hlog.RequestIDHandler("request_id", "RequestID")(handler) handler = hlog.NewHandler(*a.Log)(handler) + return handler +} + +func (a *Application) Start() { + a.Log.Info().Msg("connecting to sendgrid") + a.SendGridClient = sendgrid.NewSendClient(a.Config.SendgridAPIKey) + + a.Log.Info().Msg("Starting router") + handler := a.BuildRouter() + a.Log.Info().Msg("Listening on port 8090") http.ListenAndServe(":8090", handler) } diff --git a/internal/middleware.go b/internal/middleware.go index fb0d681..36d4b69 100644 --- a/internal/middleware.go +++ b/internal/middleware.go @@ -1,17 +1,66 @@ package internal -import "net/http" +import ( + "fmt" + "net/http" + + "github.com/golang-jwt/jwt/v4" +) + +func (a *Application) parseTokenByIssuer(tokenStr string, issuer Issuer) (bool, error) { + if tokenStr == "" { + return false, fmt.Errorf("no token") + } + + token, err := jwt.ParseWithClaims(tokenStr, &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return a.Config.ReadSecretKey(), nil + }) + if err != nil { + return false, fmt.Errorf("failed to parse token: %w", err) + } + + claims, ok := token.Claims.(*jwt.RegisteredClaims) + if !token.Valid || !ok { + return false, fmt.Errorf("invalid token") + } + + if claims.Issuer != string(issuer) { + return false, fmt.Errorf("wrong issuer: got %s, want %s", claims.Issuer, issuer) + } + + return true, nil +} func (a *Application) AdminAuthMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tok, err := r.Cookie("admin_token") if err != nil { - http.Redirect(w, r, "/", http.StatusSeeOther) + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + if isAdmin, err := a.parseTokenByIssuer(tok.Value, IssuerAdminLogin); err != nil || !isAdmin { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + + next.ServeHTTP(w, r) + }) +} + +func (a *Application) VolunteerAuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tok, err := r.Cookie("volunteer_token") + if err != nil { + http.Redirect(w, r, "/volunteer/login", http.StatusSeeOther) return } - if isAdmin, err := a.isAdminByToken(tok.Value); err != nil || !isAdmin { - http.Redirect(w, r, "/", http.StatusSeeOther) + if isVolunteer, err := a.parseTokenByIssuer(tok.Value, IssuerVolunteerLogin); err != nil || !isVolunteer { + http.Redirect(w, r, "/volunteer/login", http.StatusSeeOther) return } diff --git a/internal/middleware_test.go b/internal/middleware_test.go new file mode 100644 index 0000000..f480706 --- /dev/null +++ b/internal/middleware_test.go @@ -0,0 +1,75 @@ +package internal + +import ( + "testing" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ColoradoSchoolOfMines/mineshspc.com/internal/config" +) + +const testSecretKey = "test-secret-key" + +func newTestApp() *Application { + log := zerolog.Nop() + cfg := config.Configuration{} + cfg.JWTSecretKey = testSecretKey + return NewApplication(&log, cfg, nil) +} + +func makeSignedToken(t *testing.T, issuer Issuer, key []byte, expiry time.Time) string { + t.Helper() + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, &jwt.RegisteredClaims{ + Issuer: string(issuer), + Subject: "test@example.com", + ExpiresAt: jwt.NewNumericDate(expiry), + }) + signed, err := tok.SignedString(key) + require.NoError(t, err) + return signed +} + +// --- parseTokenByIssuer --- + +func TestParseTokenByIssuer_Empty(t *testing.T) { + a := newTestApp() + ok, err := a.parseTokenByIssuer("", IssuerAdminLogin) + assert.False(t, ok) + assert.Error(t, err) +} + +func TestParseTokenByIssuer_CorrectIssuer(t *testing.T) { + a := newTestApp() + tok := makeSignedToken(t, IssuerAdminLogin, []byte(testSecretKey), time.Now().Add(time.Hour)) + ok, err := a.parseTokenByIssuer(tok, IssuerAdminLogin) + assert.True(t, ok) + assert.NoError(t, err) +} + +func TestParseTokenByIssuer_WrongIssuer(t *testing.T) { + a := newTestApp() + tok := makeSignedToken(t, IssuerVolunteerLogin, []byte(testSecretKey), time.Now().Add(time.Hour)) + ok, err := a.parseTokenByIssuer(tok, IssuerAdminLogin) + assert.False(t, ok) + assert.Error(t, err) +} + +func TestParseTokenByIssuer_ExpiredToken(t *testing.T) { + a := newTestApp() + tok := makeSignedToken(t, IssuerAdminLogin, []byte(testSecretKey), time.Now().Add(-time.Hour)) + ok, err := a.parseTokenByIssuer(tok, IssuerAdminLogin) + assert.False(t, ok) + assert.Error(t, err) +} + +func TestParseTokenByIssuer_WrongSigningKey(t *testing.T) { + a := newTestApp() + tok := makeSignedToken(t, IssuerAdminLogin, []byte("different-key"), time.Now().Add(time.Hour)) + ok, err := a.parseTokenByIssuer(tok, IssuerAdminLogin) + assert.False(t, ok) + assert.Error(t, err) +} diff --git a/internal/routing_test.go b/internal/routing_test.go new file mode 100644 index 0000000..f4a5c06 --- /dev/null +++ b/internal/routing_test.go @@ -0,0 +1,198 @@ +package internal + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mau.fi/util/dbutil" + + "github.com/ColoradoSchoolOfMines/mineshspc.com/database" + "github.com/ColoradoSchoolOfMines/mineshspc.com/internal/config" +) + +func newTestAppWithDB(t *testing.T) *Application { + t.Helper() + log := zerolog.Nop() + + rawDB, err := dbutil.NewFromConfig("mineshspc", dbutil.Config{ + PoolConfig: dbutil.PoolConfig{ + Type: "sqlite3", + URI: ":memory:", + MaxOpenConns: 1, + MaxIdleConns: 1, + }, + }, dbutil.ZeroLogger(zerolog.Nop())) + require.NoError(t, err) + + db := database.NewDatabase(rawDB) + require.NoError(t, db.DB.Upgrade(context.Background())) + + cfg := config.Configuration{} + cfg.JWTSecretKey = testSecretKey + return NewApplication(&log, cfg, db) +} + +func adminToken(t *testing.T) string { + t.Helper() + return makeSignedToken(t, IssuerAdminLogin, []byte(testSecretKey), time.Now().Add(time.Hour)) +} + +func volunteerToken(t *testing.T) string { + t.Helper() + return makeSignedToken(t, IssuerVolunteerLogin, []byte(testSecretKey), time.Now().Add(time.Hour)) +} + +func doRequest(router http.Handler, method, path string, cookies ...*http.Cookie) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + req := httptest.NewRequest(method, path, nil) + for _, c := range cookies { + req.AddCookie(c) + } + router.ServeHTTP(rec, req) + return rec +} + +func assertRedirectsTo(t *testing.T, rec *httptest.ResponseRecorder, location string) { + t.Helper() + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Equal(t, location, rec.Header().Get("Location")) +} + +func assertNotRedirectTo(t *testing.T, rec *httptest.ResponseRecorder, location string) { + t.Helper() + if rec.Code == http.StatusSeeOther || rec.Code == http.StatusFound || rec.Code == http.StatusTemporaryRedirect { + assert.NotEqual(t, location, rec.Header().Get("Location"), + "expected route NOT to redirect to %s but it did", location) + } +} + +// --- Teacher route protection --- + +func TestRouting_Teacher_NoCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + for _, path := range []string{ + "/register/teacher/schoolinfo", + "/register/teacher/teams", + "/register/teacher/team/edit", + "/register/teacher/team/addmember", + } { + assertRedirectsTo(t, doRequest(router, http.MethodGet, path), "/register/teacher/login") + } +} + +// --- Admin route protection --- +// +// Tests cover: no cookie, malformed token, wrong-issuer token (volunteer +// token used on admin route), and valid token. Each case is verified on +// one representative route; the remaining admin routes are spot-checked +// for the no-cookie case to confirm they're all wired up. + +func TestRouting_Admin_NoCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + for _, path := range []string{ + "/admin", + "/admin/teams", + "/admin/dietaryrestrictions", + "/admin/api/resendstudentemail", + "/admin/api/sendqrcodes", + "/admin/api/kattis/teams", + "/admin/api/zoom/breakout", + "/admin/api/team-list", + } { + assertRedirectsTo(t, doRequest(router, http.MethodGet, path), "/admin/login") + } +} + +func TestRouting_Admin_MalformedToken(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + rec := doRequest(router, http.MethodGet, "/admin/teams", + &http.Cookie{Name: "admin_token", Value: "not-a-jwt"}) + assertRedirectsTo(t, rec, "/admin/login") +} + +func TestRouting_Admin_WrongIssuer(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + rec := doRequest(router, http.MethodGet, "/admin/teams", + &http.Cookie{Name: "admin_token", Value: volunteerToken(t)}) + assertRedirectsTo(t, rec, "/admin/login") +} + +func TestRouting_Admin_ValidToken(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + for _, path := range []string{ + "/admin", + "/admin/teams", + "/admin/dietaryrestrictions", + "/admin/api/kattis/teams", + "/admin/api/zoom/breakout", + "/admin/api/team-list", + } { + rec := doRequest(router, http.MethodGet, path, + &http.Cookie{Name: "admin_token", Value: adminToken(t)}) + assertNotRedirectTo(t, rec, "/admin/login") + } +} + +// --- Admin unprotected routes --- + +func TestRouting_AdminLogin_NoCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + assertNotRedirectTo(t, doRequest(router, http.MethodGet, "/admin/login"), "/admin/login") +} + +// --- Volunteer route protection --- +// +// Same structure: all protected routes checked for no-cookie, then one +// representative route for malformed/wrong-issuer/valid-token. + +func TestRouting_Volunteer_NoCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + for _, path := range []string{ + "/volunteer/scan", + "/volunteer/checkin", + } { + assertRedirectsTo(t, doRequest(router, http.MethodGet, path), "/volunteer/login") + } +} + +func TestRouting_Volunteer_MalformedToken(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + rec := doRequest(router, http.MethodGet, "/volunteer/scan", + &http.Cookie{Name: "volunteer_token", Value: "not-a-jwt"}) + assertRedirectsTo(t, rec, "/volunteer/login") +} + +func TestRouting_Volunteer_WrongIssuer(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + rec := doRequest(router, http.MethodGet, "/volunteer/scan", + &http.Cookie{Name: "volunteer_token", Value: adminToken(t)}) + assertRedirectsTo(t, rec, "/volunteer/login") +} + +func TestRouting_Volunteer_ValidToken(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + for _, path := range []string{ + "/volunteer/scan", + "/volunteer/checkin", + } { + rec := doRequest(router, http.MethodGet, path, + &http.Cookie{Name: "volunteer_token", Value: volunteerToken(t)}) + assertNotRedirectTo(t, rec, "/volunteer/login") + } +} + +// --- Volunteer unprotected routes --- + +func TestRouting_VolunteerPublic_NoCookie(t *testing.T) { + router := newTestAppWithDB(t).BuildRouter() + for _, path := range []string{"/volunteer", "/volunteer/login"} { + assertNotRedirectTo(t, doRequest(router, http.MethodGet, path), "/volunteer/login") + } +} diff --git a/internal/volunteer.go b/internal/volunteer.go index 2784a70..88b1d67 100644 --- a/internal/volunteer.go +++ b/internal/volunteer.go @@ -22,39 +22,10 @@ func (a *Application) createVolunteerLoginJWT(email string) *jwt.Token { }) } -func (a *Application) isVolunteerByToken(tokenStr string) (bool, error) { - if tokenStr == "" { - return false, errors.New("no token") - } - - token, err := jwt.ParseWithClaims(tokenStr, &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 a.Config.ReadSecretKey(), nil - }) - if err != nil { - return false, fmt.Errorf("failed to parse admin token: %w", err) - } - - claims, ok := token.Claims.(*jwt.RegisteredClaims) - if !token.Valid || !ok { - return false, fmt.Errorf("failed to validate token: %w", err) - } - - if claims.Issuer != string(IssuerVolunteerLogin) { - return false, fmt.Errorf("invalid student verify token: %w", err) - } - - return true, nil -} - func (a *Application) HandleVolunteerEmailLogin(w http.ResponseWriter, r *http.Request) { tok := r.URL.Query().Get("tok") zerolog.Ctx(r.Context()).Info().Str("token", tok).Msg("got token") - isVolunteer, err := a.isVolunteerByToken(tok) + isVolunteer, err := a.parseTokenByIssuer(tok, IssuerVolunteerLogin) if err != nil || !isVolunteer { a.Log.Warn().Msg("failed to get volunteer") w.WriteHeader(http.StatusBadRequest) @@ -121,23 +92,13 @@ func (a *Application) HandleVolunteerLogin(w http.ResponseWriter, r *http.Reques func (a *Application) GetVolunteerScanTemplate(r *http.Request) map[string]any { ctx := r.Context() - tok, err := r.Cookie("volunteer_token") - if err != nil { - a.Log.Warn().Err(err).Msg("failed to get volunteer token") - return nil - } - - if isVolunteer, err := a.isVolunteerByToken(tok.Value); err != nil || !isVolunteer { - a.Log.Warn().Err(err).Msg("user is not volunteer!") - return nil - } res := map[string]any{ "LoggedInAsVolunteer": true, } if adminTok, err := r.Cookie("admin_token"); err == nil { - if isAdmin, err := a.isAdminByToken(adminTok.Value); err == nil && isAdmin { + if isAdmin, err := a.parseTokenByIssuer(adminTok.Value, IssuerAdminLogin); err == nil && isAdmin { res["LoggedInAsAdmin"] = true } } @@ -174,18 +135,6 @@ func (a *Application) GetVolunteerScanTemplate(r *http.Request) map[string]any { func (a *Application) HandleVolunteerCheckIn(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - tok, err := r.Cookie("volunteer_token") - if err != nil { - a.Log.Warn().Err(err).Msg("failed to get volunteer token") - w.WriteHeader(http.StatusBadRequest) - return - } - - if isVolunteer, err := a.isVolunteerByToken(tok.Value); err != nil || !isVolunteer { - a.Log.Warn().Err(err).Msg("user is not volunteer!") - w.WriteHeader(http.StatusBadRequest) - return - } studentSignInToken := r.URL.Query().Get("tok") student, err := a.getStudentByQRToken(ctx, studentSignInToken)