zzh@ZZHPC:/zdata/Github/greenlight$ migrate create -seq -ext .sql -dir ./migrations create_token_table /zdata/Github/greenlight/migrations/000005_create_token_table.up.sql /zdata/Github/greenlight/migrations/000005_create_token_table.down.sql
CREATE TABLE IF NOT EXISTS token ( hash bytea PRIMARY KEY, user_id bigint NOT NULL REFERENCES users ON DELETE CASCADE, expiry timestamp(0) with time zone NOT NULL, scope text NOT NULL );
DROP TABLE IF EXISTS token;
zzh@ZZHPC:/zdata/Github/greenlight$ migrate -path ./migrations -database "$(GREENLIGHT_DB_DSN)" up 5/u create_token_table (15.073531ms)
package data import ( "context" "crypto/rand" "crypto/sha256" "encoding/base32" "time" "greenlight.zzh.net/internal/validator" ) const ScopeActivation = "activation" // Token holds the data for a token. type Token struct { Plaintext string Hash []byte UserID int64 Expiry time.Time Scope string } func generateToken(userID int64, ttl time.Duration, scope string) (*Token, error) { // We add the provided ttl (time-to-live) duration parameter to the current time // to get the expiry time. token := &Token{ UserID: userID, Expiry: time.Now().Add(ttl), Scope: scope, } // Initialize a zero-valued byte slice with a length of 16 bytes. randomBytes := make([]byte, 16) // Use the Read() function from the crypto/rand package to fill the byte slice with random // bytes from your operating system's CSPRNG. This will return an error if the CSPRNG fails // to function correctly. _, err := rand.Read(randomBytes) if err != nil { return nil, err } // Encode the byte slice to a base32-encoded string and assign it to the token's Plaintext // field. This will be the token string that we send to the user in their welcome email. // They will look similar to this: Y3QMGX3PJ3WLRL2YRTQGQ6KRHU // Note that by default base32 strings may be padded at the end with the = character. We dont' // need this padding character for the purpose of our tokens, so we omit them. token.Plaintext = base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randomBytes) // Generate a SHA-256 hash of the plaintext token string. This will be the value that we store // in the 'hash' field of our database table. Note that the sha256.Sum256() function returns // an *array* of length 32, so to make it easier to work with we convert it ot a slice using // the [:] operator before storing it. hash := sha256.Sum256([]byte(token.Plaintext)) token.Hash = hash[:] return token, nil } // ValidateTokenPlaintext validates the plaintext token is exactly 26 bytes long. func ValidateTokenPlaintext(v *validator.Validator, tokenPlaintext string) { v.Check(tokenPlaintext != "", "token", "must be provided") v.Check(len(tokenPlaintext) == 26, "token", "must be 26 bytes long") } // TokenModel struct wraps a database connection pool wrapper. type TokenModel struct { DB *PoolWrapper } // New is a shortcut which creates a new Token struct and then inserts the data in the token table. func (m TokenModel) New(userID int64, ttl time.Duration, scope string) (*Token, error) { token, err := generateToken(userID, ttl, scope) if err != nil { return nil, err } err = m.Insert(token) return token, err } // Insert inserts a new record in the token table. func (m TokenModel) Insert(token *Token) error { query := `INSERT INTO token (hash, user_id, expiry, scope) VALUES ($1, $2, $3, $4)` args := []any{token.Hash, token.UserID, token.Expiry, token.Scope} ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() _, err := m.DB.Pool.Exec(ctx, query, args...) return err } // DeleteAllForUser deletes all tokens for a specific user and scope. func (m TokenModel) DeleteAllForUser(userID int64, scope string) error { query := `DELETE FROM token WHERE user_id = $1 AND scope = $2` ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() _, err := m.DB.Pool.Exec(ctx, query, userID, scope) return err }
package main import ( "errors" "net/http" "time" "greenlight.zzh.net/internal/data" "greenlight.zzh.net/internal/validator" ) func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Request) { var input struct { Name string `json:"name"` Email string `json:"email"` Password string `json:"password"` } err := app.readJSON(w, r, &input) if err != nil { app.badRequestResponse(w, r, err) return } user := &data.User{ Name: input.Name, Email: input.Email, Activated: false, } err = user.Password.Set(input.Password) if err != nil { app.serverErrorResponse(w, r, err) return } v := validator.New() if data.ValidateUser(v, user); !v.Valid() { app.failedValidationResponse(w, r, v.Errors) return } // Insert the user data into the database. err = app.models.User.Insert(user) if err != nil { switch { case errors.Is(err, data.ErrDuplicateEmail): v.AddError("email", "a user with this email address already exists") app.failedValidationResponse(w, r, v.Errors) default: app.serverErrorResponse(w, r, err) } return } // After the user record is created in the database, generate a new activation token // for the user. token, err := app.models.Token.New(user.ID, 3*24*time.Hour, data.ScopeActivation) if err != nil { app.serverErrorResponse(w, r, err) return } // Send the welcome email in background. app.background(func() { data := map[string]any{ "activationToken": token.Plaintext, "userID": user.ID, } err = app.emailSender.Send(user.Email, "user_welcome.html", data) if err != nil { app.logger.Error(err.Error()) } }) err = app.writeJSON(w, http.StatusCreated, envelope{"user": user}, nil) if err != nil { app.serverErrorResponse(w, r, err) } }
func (app *application) activateUserHandler(w http.ResponseWriter, r *http.Request) { var input struct { TokenPlaintext string `json:"token"` } err := app.readJSON(w, r, &input) if err != nil { app.badRequestResponse(w, r, err) return } v := validator.New() if data.ValidateTokenPlaintext(v, input.TokenPlaintext); !v.Valid() { app.failedValidationResponse(w, r, v.Errors) return } user, err := app.models.User.GetForToken(data.ScopeActivation, input.TokenPlaintext) if err != nil { switch { case errors.Is(err, data.ErrRecordNotFound): v.AddError("token", "invalid or expired activation token") app.failedValidationResponse(w, r, v.Errors) default: app.serverErrorResponse(w, r, err) } return } // Update the user's activation status. user.Activated = true // Save the updated user record in database. err = app.models.User.Update(user) if err != nil { switch { case errors.Is(err, data.ErrRecordNotFound): app.editConflictResponse(w, r) default: app.serverErrorResponse(w, r, err) } return } // If everything went successfully, we delete all activation tokens for the user. err = app.models.Token.DeleteAllForUser(user.ID, data.ScopeActivation) if err != nil { app.serverErrorResponse(w, r, err) return } // Send the updated user details to the client in a JSON response. err = app.writeJSON(w, http.StatusOK, envelope{"user": user}, nil) if err != nil { app.serverErrorResponse(w, r, err) } }
// GetByToken retrives the user associated with a particular activation token from the users table. func (m UserModel) GetForToken(tokenScope, tokenPlaintext string) (*User, error) { query := `SELECT u.id, u.created_at, u.name, u.email, u.password_hash, u.activated, u.version FROM users u INNER JOIN token t ON u.id = t.user_id WHERE t.hash = $1 AND t.scope = $2 AND t.expiry > $3` tokenHash := sha256.Sum256([]byte(tokenPlaintext)) args := []any{tokenHash[:], tokenScope, time.Now()} var user User ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() err := m.DB.Pool.QueryRow(ctx, query, args...).Scan( &user.ID, &user.CreatedAt, &user.Name, &user.Email, &user.Password.hash, &user.Activated, &user.Version, ) if err != nil { switch { case errors.Is(err, pgx.ErrNoRows): return nil, ErrRecordNotFound default: return nil, err } } return &user, nil }
In token.go:
// DeleteAllForUser deletes all tokens for a specific user and scope. func (m TokenModel) DeleteAllForUser(userID int64, scope string) error { query := `DELETE FROM token WHERE user_id = $1 AND scope = $2` ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() _, err := m.DB.Pool.Exec(ctx, query, userID, scope) return err }
zzh@ZZHPC:~$ curl -X PUT -d '{"token": "invalid"}' localhost:4000/v1/users/activated { "error": { "token": "must be 26 bytes long" } } zzh@ZZHPC:~$ curl -X PUT -d '{"token": "ABCDEFGHIJKLMNOPQRSTUVWXYZ"}' localhost:4000/v1/users/activated { "error": { "token": "invalid or expired activation token" } } zzh@ZZHPC:~$ curl -X PUT -d '{"token": "BXPVBU3XI2R6PQ2FY5VGVYBI6U"}' localhost:4000/v1/users/activated { "user": { "id": 9, "created_at": "2024-11-21T21:50:00+08:00", "name": "ZhangZhihui", "email": "ZhangZhihuiAAA@126.com", "activated": true, "version": 2 } } zzh@ZZHPC:~$ curl -X PUT -d '{"token": "BXPVBU3XI2R6PQ2FY5VGVYBI6U"}' localhost:4000/v1/users/activated { "error": { "token": "invalid or expired activation token" } }
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律
2023-11-21 PASETO - Platform-Agnostic SEcurity TOkens
2023-11-21 JWT - Problem of JWT