diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 881eb51..94637b4 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -55,6 +55,7 @@ export default defineConfig({ text: 'Usage', base: '/docs/usage', items: [ {text: 'Init via Git', link: '/init-via-git'}, {text: 'Embed Gist', link: '/embed'}, + {text: 'Access Tokens', link: '/access-tokens'}, {text: 'Gist as JSON', link: '/gist-json'}, {text: 'Import Gists from Github', link: '/import-from-github-gist'}, {text: 'Git push options', link: '/git-push-options'}, diff --git a/docs/usage/access-tokens.md b/docs/usage/access-tokens.md new file mode 100644 index 0000000..8e8a3b0 --- /dev/null +++ b/docs/usage/access-tokens.md @@ -0,0 +1,26 @@ +# Access tokens + +Access tokens are used to access your private gists and their raw content. For now, it is the only use while a future API is being developed. + +## Creating an access token + +To create an access token, follow these steps: +1. Go to Settings +2. Select the "Access Tokens" menu +3. Choose a name for your token, the scope and an expiration date (optional), then click "Create Access Token" + +## Using an access token + +Once you have created an access token, you can use it to access your private gists with it. + +Replace `` with your actual access token in the following examples. + +```shell +# Access raw content of a private gist, latest revision for "file.txt". Note: this URL is obtained from the "Raw" button on the gist page. +curl -H "Authorization: Token " \ + http://opengist.example.com/user/gist/raw/HEAD/file.txt + +# Access the JSON representation of a private gist. See "Gist as JSON" documentation for more details. +curl -H "Authorization: Token " \ + http://opengist.example.com/user/gist.json +``` diff --git a/internal/db/access_token.go b/internal/db/access_token.go new file mode 100644 index 0000000..e596d58 --- /dev/null +++ b/internal/db/access_token.go @@ -0,0 +1,125 @@ +package db + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "time" +) + +const ( + NoPermission = 0 + ReadPermission = 1 + ReadWritePermission = 2 +) + +type AccessToken struct { + ID uint `gorm:"primaryKey"` + Name string + TokenHash string `gorm:"uniqueIndex,size:64"` // SHA-256 hash of the token + CreatedAt int64 + ExpiresAt int64 // 0 means no expiration + LastUsedAt int64 + UserID uint + User User `validate:"-"` + + ScopeGist uint // 0 = none, 1 = read, 2 = read+write +} + +// GenerateToken creates a new random token and returns the plain text token. +// The token hash is stored in the AccessToken struct. +// The plain text token should be shown to the user once and never stored. +func (t *AccessToken) GenerateToken() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + + plainToken := "og_" + hex.EncodeToString(bytes) + + hash := sha256.Sum256([]byte(plainToken)) + t.TokenHash = hex.EncodeToString(hash[:]) + + return plainToken, nil +} + +func GetAccessTokenByID(tokenID uint) (*AccessToken, error) { + token := new(AccessToken) + err := db. + Where("id = ?", tokenID). + First(&token).Error + return token, err +} + +func GetAccessTokenByToken(plainToken string) (*AccessToken, error) { + hash := sha256.Sum256([]byte(plainToken)) + tokenHash := hex.EncodeToString(hash[:]) + + token := new(AccessToken) + err := db. + Preload("User"). + Where("token_hash = ?", tokenHash). + First(&token).Error + return token, err +} + +func GetAccessTokensByUserID(userID uint) ([]*AccessToken, error) { + var tokens []*AccessToken + err := db. + Where("user_id = ?", userID). + Order("created_at desc"). + Find(&tokens).Error + return tokens, err +} + +func (t *AccessToken) Create() error { + t.CreatedAt = time.Now().Unix() + return db.Create(t).Error +} + +func (t *AccessToken) Delete() error { + return db.Delete(t).Error +} + +func (t *AccessToken) UpdateLastUsed() error { + return db.Model(t).Update("last_used_at", time.Now().Unix()).Error +} + +func (t *AccessToken) IsExpired() bool { + if t.ExpiresAt == 0 { + return false + } + return time.Now().Unix() > t.ExpiresAt +} + +func (t *AccessToken) HasGistReadPermission() bool { + return t.ScopeGist >= ReadPermission +} + +func (t *AccessToken) HasGistWritePermission() bool { + return t.ScopeGist >= ReadWritePermission +} + +// -- DTO -- // + +type AccessTokenDTO struct { + Name string `form:"name" validate:"required,max=255"` + ScopeGist uint `form:"scope_gist" validate:"min=0,max=2"` + ExpiresAt string `form:"expires_at"` // empty means no expiration, otherwise date format (YYYY-MM-DD) +} + +func (dto *AccessTokenDTO) ToAccessToken() *AccessToken { + var expiresAt int64 + if dto.ExpiresAt != "" { + // date input format: 2006-01-02, expires at end of day + if t, err := time.ParseInLocation("2006-01-02", dto.ExpiresAt, time.Local); err == nil { + expiresAt = t.Add(24*time.Hour - time.Second).Unix() + } + } + + return &AccessToken{ + Name: dto.Name, + ScopeGist: dto.ScopeGist, + ExpiresAt: expiresAt, + } +} diff --git a/internal/db/db.go b/internal/db/db.go index b8bb778..fd37652 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -155,7 +155,7 @@ func Setup(dbUri string) error { return err } - if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}); err != nil { + if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}, &AccessToken{}); err != nil { return err } @@ -269,5 +269,5 @@ func DeprecationDBFilename() { } func TruncateDatabase() error { - return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}) + return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}, &AccessToken{}) } diff --git a/internal/db/user.go b/internal/db/user.go index d053f0b..9bd53c3 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -25,6 +25,7 @@ type User struct { SSHKeys []SSHKey `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"` Liked []Gist `gorm:"many2many:likes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` WebAuthnCredentials []WebAuthnCredential `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"` + AccessTokens []AccessToken `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"` } func (user *User) BeforeDelete(tx *gorm.DB) error { @@ -72,6 +73,11 @@ func (user *User) BeforeDelete(tx *gorm.DB) error { return err } + err = tx.Where("user_id = ?", user.ID).Delete(&AccessToken{}).Error + if err != nil { + return err + } + err = tx.Where("user_id = ?", user.ID).Delete(&Gist{}).Error if err != nil { return err diff --git a/internal/i18n/locales/en-US.yml b/internal/i18n/locales/en-US.yml index a130cf7..d641872 100644 --- a/internal/i18n/locales/en-US.yml +++ b/internal/i18n/locales/en-US.yml @@ -157,6 +157,7 @@ settings.password-label-title: Password settings.header.account: Account settings.header.mfa: MFA settings.header.ssh: SSH +settings.header.tokens: Access tokens settings.header.style: Style settings.style.gist-code: Gist code settings.style.no-soft-wrap: No Soft Wrap @@ -169,6 +170,26 @@ settings.style.theme: Theme settings.style.theme-light: Light settings.style.theme-dark: Dark settings.style.theme-auto: Auto +settings.create-token: Create access token +settings.create-token-help: Access tokens can be used to access the API +settings.token-name: Name +settings.token-permissions: Permissions +settings.token-gist-permission: Gists +settings.token-permission-none: No access +settings.token-permission-read: Read +settings.token-permission-read-write: Read & Write +settings.delete-token: Delete +settings.delete-token-confirm: Confirm deletion of access token +settings.token-created-at: Created +settings.token-never-used: Never used +settings.token-last-used: Last used +settings.token-expiration: Expiration +settings.token-expiration-help: Leave empty for no expiration +settings.token-expires-at: Expires +settings.token-no-expiration: No expiration +settings.token-expired: expired +settings.token-created: Token created, make sure to copy it now, you won't be able to see it again! +settings.token-deleted: Access token deleted auth.signup-disabled: Administrator has disabled signing up auth.login: Login @@ -344,4 +365,4 @@ validation.not-enough: Not enough %s validation.invalid: Invalid %s validation.invalid-gist-topics: Invalid gist topics, they must start with a letter or number, consist of 50 characters or less, and can include hyphens -html.title.admin-panel: Admin panel \ No newline at end of file +html.title.admin-panel: Admin panel diff --git a/internal/web/handlers/settings/access_token.go b/internal/web/handlers/settings/access_token.go new file mode 100644 index 0000000..5c05088 --- /dev/null +++ b/internal/web/handlers/settings/access_token.go @@ -0,0 +1,75 @@ +package settings + +import ( + "strconv" + + "github.com/thomiceli/opengist/internal/db" + "github.com/thomiceli/opengist/internal/i18n" + "github.com/thomiceli/opengist/internal/validator" + "github.com/thomiceli/opengist/internal/web/context" +) + +func AccessTokens(ctx *context.Context) error { + user := ctx.User + + tokens, err := db.GetAccessTokensByUserID(user.ID) + if err != nil { + return ctx.ErrorRes(500, "Cannot get access tokens", err) + } + + ctx.SetData("accessTokens", tokens) + ctx.SetData("settingsHeaderPage", "tokens") + ctx.SetData("htmlTitle", ctx.TrH("settings")) + return ctx.Html("settings_tokens.html") +} + +func AccessTokensProcess(ctx *context.Context) error { + user := ctx.User + + dto := new(db.AccessTokenDTO) + if err := ctx.Bind(dto); err != nil { + return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err) + } + + if err := ctx.Validate(dto); err != nil { + ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error") + return ctx.RedirectTo("/settings/access-tokens") + } + + token := dto.ToAccessToken() + token.UserID = user.ID + + plainToken, err := token.GenerateToken() + if err != nil { + return ctx.ErrorRes(500, "Cannot generate token", err) + } + + if err := token.Create(); err != nil { + return ctx.ErrorRes(500, "Cannot create access token", err) + } + + // Show the token once to the user + ctx.AddFlash(ctx.Tr("settings.token-created"), "success") + ctx.AddFlash(plainToken, "success") + return ctx.RedirectTo("/settings/access-tokens") +} + +func AccessTokensDelete(ctx *context.Context) error { + user := ctx.User + tokenID, err := strconv.Atoi(ctx.Param("id")) + if err != nil { + return ctx.RedirectTo("/settings/access-tokens") + } + + token, err := db.GetAccessTokenByID(uint(tokenID)) + if err != nil || token.UserID != user.ID { + return ctx.RedirectTo("/settings/access-tokens") + } + + if err := token.Delete(); err != nil { + return ctx.ErrorRes(500, "Cannot delete access token", err) + } + + ctx.AddFlash(ctx.Tr("settings.token-deleted"), "success") + return ctx.RedirectTo("/settings/access-tokens") +} diff --git a/internal/web/server/middlewares.go b/internal/web/server/middlewares.go index ceb24e5..c06b7b2 100644 --- a/internal/web/server/middlewares.go +++ b/internal/web/server/middlewares.go @@ -326,6 +326,40 @@ func loadSettings(ctx *context.Context) error { return nil } +// getUserByToken checks the Authorization header for token-based auth. +// Expects format: Authorization: Token +// Returns the user if the token is valid and has gist read permission, nil otherwise. +func getUserByToken(ctx *context.Context) *db.User { + authHeader := ctx.Request().Header.Get("Authorization") + if authHeader == "" { + return nil + } + + if !strings.HasPrefix(authHeader, "Token ") { + return nil + } + + plainToken := strings.TrimPrefix(authHeader, "Token ") + + accessToken, err := db.GetAccessTokenByToken(plainToken) + if err != nil { + return nil + } + + if accessToken.IsExpired() { + return nil + } + + if !accessToken.HasGistReadPermission() { + return nil + } + + // Update last used timestamp + _ = accessToken.UpdateLastUsed() + + return &accessToken.User +} + func gistInit(next Handler) Handler { return func(ctx *context.Context) error { currUser := ctx.User @@ -352,7 +386,12 @@ func gistInit(next Handler) Handler { if gist.Private == db.PrivateVisibility { if currUser == nil || currUser.ID != gist.UserID { - return ctx.NotFound("Gist not found") + // Check for token-based auth via Authorization header + if tokenUser := getUserByToken(ctx); tokenUser != nil && tokenUser.ID == gist.UserID { + // Token is valid and belongs to gist owner, allow access + } else { + return ctx.NotFound("Gist not found") + } } } diff --git a/internal/web/server/renderer.go b/internal/web/server/renderer.go index 99391c6..ef7915e 100644 --- a/internal/web/server/renderer.go +++ b/internal/web/server/renderer.go @@ -192,6 +192,9 @@ func (s *Server) setFuncMap() { "humanDate": func(t int64) string { return time.Unix(t, 0).Format("02/01/2006 15:04") }, + "humanDateOnly": func(t int64) string { + return time.Unix(t, 0).Format("02/01/2006") + }, "mainTheme": func(theme *db.UserStyleDTO) string { if theme == nil { return "auto" diff --git a/internal/web/server/router.go b/internal/web/server/router.go index 6fc7c67..99a44ce 100644 --- a/internal/web/server/router.go +++ b/internal/web/server/router.go @@ -62,6 +62,9 @@ func (s *Server) registerRoutes() { sA.DELETE("/account", settings.AccountDeleteProcess) sA.POST("/ssh-keys", settings.SshKeysProcess) sA.DELETE("/ssh-keys/:id", settings.SshKeysDelete) + sA.GET("/access-tokens", settings.AccessTokens) + sA.POST("/access-tokens", settings.AccessTokensProcess) + sA.DELETE("/access-tokens/:id", settings.AccessTokensDelete) sA.DELETE("/passkeys/:id", settings.PasskeyDelete) sA.PUT("/password", settings.PasswordProcess) sA.PUT("/username", settings.UsernameProcess) diff --git a/internal/web/test/access_token_test.go b/internal/web/test/access_token_test.go new file mode 100644 index 0000000..4093bb7 --- /dev/null +++ b/internal/web/test/access_token_test.go @@ -0,0 +1,361 @@ +package test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/thomiceli/opengist/internal/db" +) + +func TestAccessTokensCRUD(t *testing.T) { + s := Setup(t) + defer Teardown(t, s) + + // Register and login + user1 := db.UserDTO{Username: "thomas", Password: "thomas"} + register(t, s, user1) + + // Access tokens page requires login + s.sessionCookie = "" + err := s.Request("GET", "/settings/access-tokens", nil, 302) + require.NoError(t, err) + + login(t, s, user1) + + // Access tokens page + err = s.Request("GET", "/settings/access-tokens", nil, 200) + require.NoError(t, err) + + // Create a token with read permission + tokenDTO := db.AccessTokenDTO{ + Name: "test-token", + ScopeGist: db.ReadPermission, + } + err = s.Request("POST", "/settings/access-tokens", tokenDTO, 302) + require.NoError(t, err) + + // Verify token was created in database + tokens, err := db.GetAccessTokensByUserID(1) + require.NoError(t, err) + require.Len(t, tokens, 1) + require.Equal(t, "test-token", tokens[0].Name) + require.Equal(t, uint(db.ReadPermission), tokens[0].ScopeGist) + require.Equal(t, int64(0), tokens[0].ExpiresAt) + + // Create another token with expiration + tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02") + tokenDTO2 := db.AccessTokenDTO{ + Name: "expiring-token", + ScopeGist: db.ReadWritePermission, + ExpiresAt: tomorrow, + } + err = s.Request("POST", "/settings/access-tokens", tokenDTO2, 302) + require.NoError(t, err) + + tokens, err = db.GetAccessTokensByUserID(1) + require.NoError(t, err) + require.Len(t, tokens, 2) + + // Delete the first token + err = s.Request("DELETE", "/settings/access-tokens/1", nil, 302) + require.NoError(t, err) + + tokens, err = db.GetAccessTokensByUserID(1) + require.NoError(t, err) + require.Len(t, tokens, 1) + require.Equal(t, "expiring-token", tokens[0].Name) +} + +func TestAccessTokenPrivateGistAccess(t *testing.T) { + s := Setup(t) + defer Teardown(t, s) + + // Register user and create a private gist + user1 := db.UserDTO{Username: "thomas", Password: "thomas"} + register(t, s, user1) + + gist1 := db.GistDTO{ + Title: "private-gist", + Description: "my private gist", + VisibilityDTO: db.VisibilityDTO{ + Private: db.PrivateVisibility, + }, + Name: []string{"secret.txt"}, + Content: []string{"secret content"}, + } + err := s.Request("POST", "/", gist1, 302) + require.NoError(t, err) + + gist1db, err := db.GetGistByID("1") + require.NoError(t, err) + + // Create access token with read permission + token := &db.AccessToken{ + Name: "read-token", + UserID: 1, + ScopeGist: db.ReadPermission, + } + plainToken, err := token.GenerateToken() + require.NoError(t, err) + err = token.Create() + require.NoError(t, err) + + // Clear session - simulate unauthenticated request + s.sessionCookie = "" + + // Without token, private gist should return 404 + err = s.Request("GET", "/thomas/"+gist1db.Uuid, nil, 404) + require.NoError(t, err) + + // With valid token, private gist should be accessible + headers := map[string]string{"Authorization": "Token " + plainToken} + err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, headers) + require.NoError(t, err) + + // Raw content should also be accessible with token + err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid+"/raw/HEAD/secret.txt", nil, 200, headers) + require.NoError(t, err) + + // JSON endpoint should also be accessible with token + err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid+".json", nil, 200, headers) + require.NoError(t, err) + + // Invalid token should not work + invalidHeaders := map[string]string{"Authorization": "Token invalid_token"} + err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 404, invalidHeaders) + require.NoError(t, err) +} + +func TestAccessTokenPermissions(t *testing.T) { + s := Setup(t) + defer Teardown(t, s) + + // Register user and create a private gist + user1 := db.UserDTO{Username: "thomas", Password: "thomas"} + register(t, s, user1) + + gist1 := db.GistDTO{ + Title: "private-gist", + Description: "my private gist", + VisibilityDTO: db.VisibilityDTO{ + Private: db.PrivateVisibility, + }, + Name: []string{"file.txt"}, + Content: []string{"content"}, + } + err := s.Request("POST", "/", gist1, 302) + require.NoError(t, err) + + gist1db, err := db.GetGistByID("1") + require.NoError(t, err) + + // Create token with NO permission + noPermToken := &db.AccessToken{ + Name: "no-perm-token", + UserID: 1, + ScopeGist: db.NoPermission, + } + noPermPlain, err := noPermToken.GenerateToken() + require.NoError(t, err) + err = noPermToken.Create() + require.NoError(t, err) + + // Create token with READ permission + readToken := &db.AccessToken{ + Name: "read-token", + UserID: 1, + ScopeGist: db.ReadPermission, + } + readPlain, err := readToken.GenerateToken() + require.NoError(t, err) + err = readToken.Create() + require.NoError(t, err) + + s.sessionCookie = "" + + // No permission token should not grant access + noPermHeaders := map[string]string{"Authorization": "Token " + noPermPlain} + err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 404, noPermHeaders) + require.NoError(t, err) + + // Read permission token should grant access + readHeaders := map[string]string{"Authorization": "Token " + readPlain} + err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, readHeaders) + require.NoError(t, err) +} + +func TestAccessTokenExpiration(t *testing.T) { + s := Setup(t) + defer Teardown(t, s) + + // Register user and create a private gist + user1 := db.UserDTO{Username: "thomas", Password: "thomas"} + register(t, s, user1) + + gist1 := db.GistDTO{ + Title: "private-gist", + Description: "my private gist", + VisibilityDTO: db.VisibilityDTO{ + Private: db.PrivateVisibility, + }, + Name: []string{"file.txt"}, + Content: []string{"content"}, + } + err := s.Request("POST", "/", gist1, 302) + require.NoError(t, err) + + gist1db, err := db.GetGistByID("1") + require.NoError(t, err) + + // Create an expired token + expiredToken := &db.AccessToken{ + Name: "expired-token", + UserID: 1, + ScopeGist: db.ReadPermission, + ExpiresAt: time.Now().Add(-24 * time.Hour).Unix(), // Expired yesterday + } + expiredPlain, err := expiredToken.GenerateToken() + require.NoError(t, err) + err = expiredToken.Create() + require.NoError(t, err) + + // Create a valid (non-expired) token + validToken := &db.AccessToken{ + Name: "valid-token", + UserID: 1, + ScopeGist: db.ReadPermission, + ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), // Expires tomorrow + } + validPlain, err := validToken.GenerateToken() + require.NoError(t, err) + err = validToken.Create() + require.NoError(t, err) + + s.sessionCookie = "" + + // Expired token should not grant access + expiredHeaders := map[string]string{"Authorization": "Token " + expiredPlain} + err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 404, expiredHeaders) + require.NoError(t, err) + + // Valid token should grant access + validHeaders := map[string]string{"Authorization": "Token " + validPlain} + err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, validHeaders) + require.NoError(t, err) +} + +func TestAccessTokenWrongUser(t *testing.T) { + s := Setup(t) + defer Teardown(t, s) + + // Register two users + user1 := db.UserDTO{Username: "thomas", Password: "thomas"} + register(t, s, user1) + + // Create a private gist for user1 + gist1 := db.GistDTO{ + Title: "thomas-private-gist", + Description: "thomas private gist", + VisibilityDTO: db.VisibilityDTO{ + Private: db.PrivateVisibility, + }, + Name: []string{"file.txt"}, + Content: []string{"content"}, + } + err := s.Request("POST", "/", gist1, 302) + require.NoError(t, err) + + gist1db, err := db.GetGistByID("1") + require.NoError(t, err) + + s.sessionCookie = "" + user2 := db.UserDTO{Username: "kaguya", Password: "kaguya"} + register(t, s, user2) + + // Create token for user2 + user2Token := &db.AccessToken{ + Name: "kaguya-token", + UserID: 2, + ScopeGist: db.ReadPermission, + } + user2Plain, err := user2Token.GenerateToken() + require.NoError(t, err) + err = user2Token.Create() + require.NoError(t, err) + + s.sessionCookie = "" + + // User2's token should NOT grant access to user1's private gist + user2Headers := map[string]string{"Authorization": "Token " + user2Plain} + err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 404, user2Headers) + require.NoError(t, err) + + // Create token for user1 + user1Token := &db.AccessToken{ + Name: "thomas-token", + UserID: 1, + ScopeGist: db.ReadPermission, + } + user1Plain, err := user1Token.GenerateToken() + require.NoError(t, err) + err = user1Token.Create() + require.NoError(t, err) + + // User1's token SHOULD grant access to user1's private gist + user1Headers := map[string]string{"Authorization": "Token " + user1Plain} + err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, user1Headers) + require.NoError(t, err) +} + +func TestAccessTokenLastUsedUpdate(t *testing.T) { + s := Setup(t) + defer Teardown(t, s) + + // Register user and create a private gist + user1 := db.UserDTO{Username: "thomas", Password: "thomas"} + register(t, s, user1) + + gist1 := db.GistDTO{ + Title: "private-gist", + Description: "my private gist", + VisibilityDTO: db.VisibilityDTO{ + Private: db.PrivateVisibility, + }, + Name: []string{"file.txt"}, + Content: []string{"content"}, + } + err := s.Request("POST", "/", gist1, 302) + require.NoError(t, err) + + gist1db, err := db.GetGistByID("1") + require.NoError(t, err) + + // Create token + token := &db.AccessToken{ + Name: "test-token", + UserID: 1, + ScopeGist: db.ReadPermission, + } + plainToken, err := token.GenerateToken() + require.NoError(t, err) + err = token.Create() + require.NoError(t, err) + + // Verify LastUsedAt is 0 initially + tokenFromDB, err := db.GetAccessTokenByID(token.ID) + require.NoError(t, err) + require.Equal(t, int64(0), tokenFromDB.LastUsedAt) + + s.sessionCookie = "" + + // Use the token + headers := map[string]string{"Authorization": "Token " + plainToken} + err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, headers) + require.NoError(t, err) + + // Verify LastUsedAt was updated + tokenFromDB, err = db.GetAccessTokenByID(token.ID) + require.NoError(t, err) + require.NotEqual(t, int64(0), tokenFromDB.LastUsedAt) +} diff --git a/internal/web/test/server.go b/internal/web/test/server.go index 7dfe9a6..50b2fad 100644 --- a/internal/web/test/server.go +++ b/internal/web/test/server.go @@ -51,6 +51,10 @@ func (s *TestServer) stop() { } func (s *TestServer) Request(method, uri string, data interface{}, expectedCode int, responsePtr ...*http.Response) error { + return s.RequestWithHeaders(method, uri, data, expectedCode, nil, responsePtr...) +} + +func (s *TestServer) RequestWithHeaders(method, uri string, data interface{}, expectedCode int, headers map[string]string, responsePtr ...*http.Response) error { var bodyReader io.Reader if method == http.MethodPost || method == http.MethodPut { values := structToURLValues(data) @@ -64,6 +68,10 @@ func (s *TestServer) Request(method, uri string, data interface{}, expectedCode req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } + for key, value := range headers { + req.Header.Set(key, value) + } + if s.sessionCookie != "" { req.AddCookie(&http.Cookie{Name: "session", Value: s.sessionCookie}) } @@ -120,6 +128,9 @@ func structToURLValues(s interface{}) url.Values { if field.Type.Kind() == reflect.Int { fieldValue := rValue.Field(i).Int() v.Add(tag, strconv.FormatInt(fieldValue, 10)) + } else if field.Type.Kind() == reflect.Uint { + fieldValue := rValue.Field(i).Uint() + v.Add(tag, strconv.FormatUint(fieldValue, 10)) } else if field.Type.Kind() == reflect.Slice { fieldValue := rValue.Field(i).Interface().([]string) for _, va := range fieldValue { diff --git a/templates/base/settings_header.html b/templates/base/settings_header.html index e056bdb..a0bf07f 100644 --- a/templates/base/settings_header.html +++ b/templates/base/settings_header.html @@ -15,6 +15,8 @@ {{ else }} text-gray-600 dark:text-gray-400 hover:text-gray-400 dark:hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}" aria-current="page">{{ .locale.Tr "settings.header.mfa" }} {{ .locale.Tr "settings.header.ssh" }} + {{ .locale.Tr "settings.header.tokens" }} {{ .locale.Tr "settings.header.style" }} diff --git a/templates/pages/settings_tokens.html b/templates/pages/settings_tokens.html new file mode 100644 index 0000000..baf35d1 --- /dev/null +++ b/templates/pages/settings_tokens.html @@ -0,0 +1,95 @@ +{{ template "header" .}} +{{ template "settings_header" .}} +
+
+
+
+

+ {{ .locale.Tr "settings.create-token" }} +

+

+ {{ .locale.Tr "settings.create-token-help" }} +

+
+
+ +
+ +
+
+ +
+ +
+ + +
+
+ +
+ +

+ {{ .locale.Tr "settings.token-expiration-help" }} +

+
+ +
+
+ + + {{ .csrfHtml }} +
+
+
+
+
+
    + {{ if .accessTokens }} + {{ range $token := .accessTokens }} +
  • +
    + + + +
    +

    {{ .Name }}

    +

    + {{ $.locale.Tr "settings.token-gist-permission" }}: + {{ if eq .ScopeGist 0 }}{{ $.locale.Tr "settings.token-permission-none" }}{{ end }} + {{ if eq .ScopeGist 1 }}{{ $.locale.Tr "settings.token-permission-read" }}{{ end }} + {{ if eq .ScopeGist 2 }}{{ $.locale.Tr "settings.token-permission-read-write" }}{{ end }} +

    +

    {{ $.locale.Tr "settings.token-created-at" }} {{ .CreatedAt | humanDate }}

    + {{ if eq .ExpiresAt 0 }} +

    {{ $.locale.Tr "settings.token-no-expiration" }}

    + {{ else }} +

    {{ $.locale.Tr "settings.token-expires-at" }} {{ .ExpiresAt | humanDateOnly }}{{ if .IsExpired }} ({{ $.locale.Tr "settings.token-expired" }}){{ end }}

    + {{ end }} + {{ if eq .LastUsedAt 0 }} +

    {{ $.locale.Tr "settings.token-never-used" }}

    + {{ else }} +

    {{ $.locale.Tr "settings.token-last-used" }} {{ .LastUsedAt | humanTimeDiff }}

    + {{ end }} +
    +
    + + {{ $.csrfHtml }} + + +
    +
    +
  • + {{ end }} + {{ end }} +
+
+
+
+
+ +{{ template "settings_footer" .}} +{{ template "footer" .}}