Add access tokens (#602)

This commit is contained in:
Thomas Miceli
2026-01-27 14:43:12 +08:00
committed by GitHub
parent 1128a81071
commit 5ddea2265d
14 changed files with 772 additions and 4 deletions

125
internal/db/access_token.go Normal file
View File

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

View File

@@ -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{})
}

View File

@@ -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

View File

@@ -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
html.title.admin-panel: Admin panel

View File

@@ -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")
}

View File

@@ -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 <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")
}
}
}

View File

@@ -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"

View File

@@ -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)

View File

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

View File

@@ -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 {