diff --git a/backend/db/tokens.go b/backend/db/tokens.go new file mode 100644 index 0000000..6e07367 --- /dev/null +++ b/backend/db/tokens.go @@ -0,0 +1,98 @@ +package db + +import ( + "context" + "time" + + "emperror.dev/errors" + "github.com/georgysavva/scany/pgxscan" + "github.com/jackc/pgx/v4" + "github.com/rs/xid" +) + +type Token struct { + UserID xid.ID + TokenID xid.ID + Invalidated bool + Created time.Time + Expires time.Time +} + +func (db *DB) TokenValid(ctx context.Context, userID, tokenID xid.ID) (valid bool, err error) { + sql, args, err := sq.Select("*").From("tokens"). + Where("user_id = ?", userID). + Where("token_id = ?", tokenID). + ToSql() + if err != nil { + return false, errors.Wrap(err, "building sql") + } + + var t Token + err = pgxscan.Get(ctx, db, &t, sql, args...) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return false, nil + } + + return false, errors.Wrap(err, "getting from database") + } + + now := time.Now() + return !t.Invalidated && t.Created.Before(now) && t.Expires.After(now), nil +} + +func (db *DB) Tokens(ctx context.Context, userID xid.ID) (ts []Token, err error) { + sql, args, err := sq.Select("*").From("tokens"). + Where("user_id = ?", userID). + Where("expires > ?", time.Now()). + OrderBy("created"). + ToSql() + if err != nil { + return nil, errors.Wrap(err, "building sql") + } + + err = pgxscan.Select(ctx, db, &ts, sql, args...) + if err != nil { + return nil, errors.Wrap(err, "getting from database") + } + return ts, nil +} + +// 3 months, might be customizable later +const ExpiryTime = 3 * 30 * 24 * time.Hour + +// SaveToken saves a token to the database. +func (db *DB) SaveToken(ctx context.Context, userID xid.ID, tokenID xid.ID) (t Token, err error) { + sql, args, err := sq.Insert("tokens"). + Columns("user_id", "token_id", "expires"). + Values(userID, tokenID, time.Now().Add(ExpiryTime)). + Suffix("RETURNING *"). + ToSql() + if err != nil { + return t, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &t, sql, args...) + if err != nil { + return t, errors.Wrap(err, "inserting token") + } + return t, nil +} + +func (db *DB) InvalidateToken(ctx context.Context, userID xid.ID, tokenID xid.ID) (t Token, err error) { + sql, args, err := sq.Update("tokens"). + Where("user_id = ?"). + Where("token_id = ?"). + Set("invalidated", true). + Suffix("RETURNING *"). + ToSql() + if err != nil { + return t, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &t, sql, args...) + if err != nil { + return t, errors.Wrap(err, "invalidating token") + } + return t, nil +} diff --git a/backend/routes/auth/discord.go b/backend/routes/auth/discord.go index 819f6d1..8b1bed5 100644 --- a/backend/routes/auth/discord.go +++ b/backend/routes/auth/discord.go @@ -11,6 +11,7 @@ import ( "github.com/bwmarrin/discordgo" "github.com/go-chi/render" "github.com/mediocregopher/radix/v4" + "github.com/rs/xid" "golang.org/x/oauth2" ) @@ -81,11 +82,19 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { log.Errorf("updating user %v with Discord info: %v", u.ID, err) } - token, err := s.Auth.CreateToken(u.ID) + // TODO: implement user + token permissions + tokenID := xid.New() + token, err := s.Auth.CreateToken(u.ID, tokenID, false, true) if err != nil { return err } + // save token to database + _, err = s.DB.SaveToken(ctx, u.ID, tokenID) + if err != nil { + return errors.Wrap(err, "saving token to database") + } + render.JSON(w, r, discordCallbackResponse{ HasAccount: true, Token: token, @@ -206,11 +215,19 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error { } // create token - token, err := s.Auth.CreateToken(u.ID) + // TODO: implement user + token permissions + tokenID := xid.New() + token, err := s.Auth.CreateToken(u.ID, tokenID, false, true) if err != nil { return errors.Wrap(err, "creating token") } + // save token to database + _, err = s.DB.SaveToken(ctx, u.ID, tokenID) + if err != nil { + return errors.Wrap(err, "saving token to database") + } + // return user render.JSON(w, r, signupResponse{ User: *dbUserToUserResponse(u), diff --git a/backend/routes/auth/routes.go b/backend/routes/auth/routes.go index b5324fc..67f148c 100644 --- a/backend/routes/auth/routes.go +++ b/backend/routes/auth/routes.go @@ -67,6 +67,11 @@ func Mount(srv *server.Server, r chi.Router) { // invite routes r.With(server.MustAuth).Get("/invites", server.WrapHandler(s.getInvites)) r.With(server.MustAuth).Post("/invites", server.WrapHandler(s.createInvite)) + + // tokens + r.With(server.MustAuth).Get("/tokens", server.WrapHandler(s.getTokens)) + r.With(server.MustAuth).Post("/tokens", server.WrapHandler(s.createToken)) + r.With(server.MustAuth).Delete("/tokens", server.WrapHandler(s.deleteToken)) }) } diff --git a/backend/routes/auth/tokens.go b/backend/routes/auth/tokens.go new file mode 100644 index 0000000..447d265 --- /dev/null +++ b/backend/routes/auth/tokens.go @@ -0,0 +1,72 @@ +package auth + +import ( + "net/http" + "time" + + "codeberg.org/u1f320/pronouns.cc/backend/db" + "codeberg.org/u1f320/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/render" + "github.com/rs/xid" +) + +type getTokenResponse struct { + TokenID xid.ID `json:"id"` + Created time.Time `json:"created"` + Expires time.Time `json:"expires"` +} + +func dbTokenToGetResponse(t db.Token) getTokenResponse { + return getTokenResponse{ + TokenID: t.TokenID, + Created: t.Created, + Expires: t.Expires, + } +} + +func (s *Server) getTokens(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + tokens, err := s.DB.Tokens(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting tokens") + } + + resps := make([]getTokenResponse, len(tokens)) + for i := range tokens { + resps[i] = dbTokenToGetResponse(tokens[i]) + } + + render.JSON(w, r, resps) + return nil +} + +type deleteTokenResponse struct { + TokenID xid.ID `json:"id"` + Invalidated bool `json:"invalidated"` + Created time.Time `json:"time"` +} + +func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + t, err := s.DB.InvalidateToken(ctx, claims.UserID, claims.TokenID) + if err != nil { + return errors.Wrap(err, "invalidating token") + } + + render.JSON(w, r, deleteTokenResponse{ + TokenID: t.TokenID, + Invalidated: t.Invalidated, + Created: t.Created, + }) + return nil +} + +func (s *Server) createToken(w http.ResponseWriter, r *http.Request) error { + // unimplemented right now + return server.APIError{Code: server.ErrForbidden} +} diff --git a/backend/server/auth.go b/backend/server/auth.go index 6113ba3..915dc77 100644 --- a/backend/server/auth.go +++ b/backend/server/auth.go @@ -5,6 +5,7 @@ import ( "net/http" "strings" + "codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/u1f320/pronouns.cc/backend/server/auth" "github.com/go-chi/render" ) @@ -28,6 +29,27 @@ func (s *Server) maybeAuth(next http.Handler) http.Handler { return } + // "valid" here refers to existence and expiry date, not whether the token is known + valid, err := s.DB.TokenValid(r.Context(), claims.UserID, claims.TokenID) + if err != nil { + log.Errorf("validating token for user %v: %v", claims.UserID, err) + render.Status(r, errCodeStatuses[ErrInternalServerError]) + render.JSON(w, r, APIError{ + Code: ErrInternalServerError, + Message: errCodeMessages[ErrInternalServerError], + }) + return + } + + if !valid { + render.Status(r, errCodeStatuses[ErrInvalidToken]) + render.JSON(w, r, APIError{ + Code: ErrInvalidToken, + Message: errCodeMessages[ErrInvalidToken], + }) + return + } + ctx := context.WithValue(r.Context(), ctxKeyClaims, claims) next.ServeHTTP(w, r.WithContext(ctx)) diff --git a/backend/server/auth/auth.go b/backend/server/auth/auth.go index 8d756ce..07f1072 100644 --- a/backend/server/auth/auth.go +++ b/backend/server/auth/auth.go @@ -14,7 +14,13 @@ import ( // Claims are the claims used in a token. type Claims struct { - UserID xid.ID `json:"sub"` + UserID xid.ID `json:"sub"` + TokenID xid.ID `json:"jti"` + UserIsAdmin bool `json:"adm"` + + // TokenWrite specifies whether this token can be used for write actions. + // If set to false, this token can only be used for read actions. + TokenWrite bool `json:"twr"` jwt.RegisteredClaims } @@ -37,16 +43,20 @@ func New() *Verifier { return &Verifier{key: key} } -const expireDays = 30 +// ExpireDays is after how many days the token will expire. +const ExpireDays = 30 // CreateToken creates a token for the given user ID. // It expires after 30 days. -func (v *Verifier) CreateToken(userID xid.ID) (string, error) { +func (v *Verifier) CreateToken(userID, tokenID xid.ID, isAdmin bool, isWriteToken bool) (token string, err error) { now := time.Now() - expires := now.Add(expireDays * 24 * time.Hour) + expires := now.Add(ExpireDays * 24 * time.Hour) - token := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{ - UserID: userID, + t := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{ + UserID: userID, + TokenID: tokenID, + UserIsAdmin: isAdmin, + TokenWrite: isWriteToken, RegisteredClaims: jwt.RegisteredClaims{ Issuer: "pronouns", ExpiresAt: jwt.NewNumericDate(expires), @@ -55,7 +65,7 @@ func (v *Verifier) CreateToken(userID xid.ID) (string, error) { }, }) - return token.SignedString(v.key) + return t.SignedString(v.key) } // Claims parses the given token and returns its Claims. diff --git a/scripts/migrate/003_add_tokens.sql b/scripts/migrate/003_add_tokens.sql new file mode 100644 index 0000000..a6d81ef --- /dev/null +++ b/scripts/migrate/003_add_tokens.sql @@ -0,0 +1,15 @@ +-- +migrate Up + +-- 2022-12-23: Add database-backed tokens +create table tokens ( + user_id text not null references users (id) on delete cascade, + token_id text primary key, + invalidated boolean not null default false, + created timestamptz not null default now(), + expires timestamptz not null +); + +-- Unrelatedly, this migration also changes the column type for invites.created to timestamptz (from plain timestamp) +-- This does not change anything code-wise, but it's recommended over plain timestamp because plain timestamp does not handle timezones correctly +alter table invites alter column created type timestamptz; +alter table invites alter column created set default now();