我正在尝试创建一个 Web 管理仪表板,使用带有 redux-saga 的 Typescript 前端和后端 Docker、Golang、Postgres 作为“主”数据库和 Redis 来存储访问令牌;我遵循了这个tutorial及其相应的repo的想法。我现在专注于 JWT 部分。
这是处理登录请求的函数,应该为用户提供令牌:
func (h *Handler) signIn(c *gin.Context) {
var req apipayloads.SignInRequestPayload
if ok := bindData(c, &req); !ok {
return
}
u := &models.User{
Email: req.Email,
Password: req.Password,
}
ctx := c.Request.Context()
err := h.UserService.SignIn(ctx, u)
if err != nil {
log.Printf("Failed to sign in user: %v\n", err.Error())
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
tokens, err := h.TokenService.NewTokenPair(ctx, u, "")
if err != nil {
log.Printf("Failed to create tokens for user: %v\n", err.Error())
c.JSON(apperrors.Status(err), gin.H{
"error": err,
})
return
}
c.JSON(http.StatusOK, gin.H{
"accessToken": tokens.AccessToken,
"refreshToken": tokens.RefreshToken,
"user": &models.UserPublic{
Name: u.Name,
Surname: u.Surname,
Email: u.Email,
IsAdmin: u.IsAdmin,
},
})
和 NewTokenPair 函数:
func (s *tokenService) NewTokenPair(ctx context.Context, u *models.User, prevTokenID string) (*models.Token, error) {
// No need to use a repository for idToken as it is unrelated to any data source
idToken, err := utils.GenerateAccessToken(u, s.PrivKey, s.AccessExpiration)
if err != nil {
log.Printf("Error generating accessToken for uid: %v. Error: %v\n", u.UID, err.Error())
return nil, apperrors.NewInternal()
}
refreshToken, err := utils.GenerateRefreshToken(u.UID, s.RefreshSecret, s.RefreshExpiration)
if err != nil {
log.Printf("Error generating refreshToken for uid: %v. Error: %v\n", u.UID, err.Error())
return nil, apperrors.NewInternal()
}
// set freshly minted refresh token to valid list
if err := s.TokenRepository.SetRefreshToken(ctx, u.UID.String(), refreshToken.ID, refreshToken.ExpiresIn); err != nil {
log.Printf("Error storing tokenID for uid: %v. Error: %v\n", u.UID, err.Error())
return nil, apperrors.NewInternal()
}
// delete user's current refresh token (used when refreshing idToken)
if prevTokenID != "" {
if err := s.TokenRepository.DeleteRefreshToken(ctx, u.UID.String(), prevTokenID); err != nil {
log.Printf("Could not delete previous refreshToken for uid: %v, tokenID: %v\n", u.UID.String(), prevTokenID)
}
}
return &models.Token{
AccessToken: idToken,
RefreshToken: refreshToken.SS,
}, nil
}
我的问题是,当我从 Postman 调用登录端点时,我获得了正确的响应。当我从用 Typescript 创建的前端调用它时,我得到
Could not SET refresh token to redis for userID/tokenID: 8f1f51fe-87f4-49af-8e1b-d0ec1b8d6504/de20cfa1-18a7-44e5-8abb-ad8009fe39d3: context canceled
我在网上看到问题可能是由于
context.WithTimeout
造成的,但我尝试用context.Background
替换它,但没有任何改变......而且由于这是我第一次同时使用Go和Redis,我不知道在哪里否则我什至可以搜索问题。
如果您需要更多详细信息或其他代码片段来了解问题出在哪里,请告诉我。ù
正如评论中所问,这是我使用 context.WithTimeout() 的部分:
func Timeout(timeout time.Duration, errTimeout *apperrors.Error) gin.HandlerFunc {
return func(c *gin.Context) {
// set Gin's writer as our custom writer
tw := &timeoutWriter{ResponseWriter: c.Writer, h: make(http.Header)}
c.Writer = tw
// wrap the request context with a timeout
ctx, cancel := context.WithTimeout(c.Request.Context(), timeout)
defer cancel()
// update gin request context
c.Request = c.Request.WithContext(ctx)
finished := make(chan struct{}) // to indicate handler finished
panicChan := make(chan interface{}, 1) // used to handle panics if we can't recover
go func() {
defer func() {
if p := recover(); p != nil {
panicChan <- p
}
}()
c.Next() // calls subsequent middleware(s) and handler
finished <- struct{}{}
}()
select {
case <-panicChan:
// if we cannot recover from panic,
// send internal server error
e := apperrors.NewInternal()
tw.ResponseWriter.WriteHeader(e.Status())
eResp, _ := json.Marshal(gin.H{
"error": e,
})
tw.ResponseWriter.Write(eResp)
case <-finished:
// if finished, set headers and write resp
tw.mu.Lock()
defer tw.mu.Unlock()
// map Headers from tw.Header() (written to by gin)
// to tw.ResponseWriter for response
dst := tw.ResponseWriter.Header()
for k, vv := range tw.Header() {
dst[k] = vv
}
tw.ResponseWriter.WriteHeader(tw.code)
// tw.wbuf will have been written to already when gin writes to tw.Write()
tw.ResponseWriter.Write(tw.wbuf.Bytes())
case <-ctx.Done():
// timeout has occurred, send errTimeout and write headers
tw.mu.Lock()
defer tw.mu.Unlock()
// ResponseWriter from gin
tw.ResponseWriter.Header().Set("Content-Type", "application/json")
tw.ResponseWriter.WriteHeader(errTimeout.Status())
eResp, _ := json.Marshal(gin.H{
"error": errTimeout,
})
tw.ResponseWriter.Write(eResp)
c.Abort()
tw.SetTimedOut()
}
}
}
以及,当我创建处理程序时:
type Handler struct {
UserService interfaces.IUserService
TokenService interfaces.ITokenService
}
type Config struct {
R *gin.Engine
UserService interfaces.IUserService
TokenService interfaces.ITokenService
TimeoutDuration time.Duration
}
func NewHandler(c *Config) {
h := &Handler{
UserService: c.UserService,
TokenService: c.TokenService,
}
// Create an account group
auth := c.R.Group(os.Getenv("AUTH_URL"))
if gin.Mode() != gin.TestMode {
auth.Use(middleware.Timeout(c.TimeoutDuration, apperrors.NewServiceUnavailable()))
auth.POST("/signIn", h.signIn)
} else {
auth.POST("/signIn", h.signIn)
}
}
我也尝试过删除这部分,然后就这样做了
auth := c.R.Group(os.Getenv("AUTH_URL"))
auth.POST("/signIn", h.signIn)
但什么都没有改变。
我设计了一个登录页面,用户需要在其中插入电子邮件和密码,以及一个执行请求的按钮。此页面位于 url http://localhost:3001 上。按下按钮后,会重定向到 http://localhost:3001/email=testdeffo%40test.com&password=test。这是开发人员工具中的调用堆栈的样子:
第一个调用使用 OPTIONS 方法,第三个调用是 GET。中间的那个是这样的:
这是 saga.ts 代码:
const login = async (payload: SignInRequestPayload) => {
await axios.post<SignInResponsePayload[]>(
(Config.test ? webapi.testUrl : webapi.prodUrl) + webapi.auth.signIn,
{
...payload,
},
{
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
}
);
};
function* loginSaga(action: any) {
try {
const response: SignInSuccessPayload = yield call(login, {
email: action.payload.email,
password: action.payload.password,
});
console.log("LOG 1")
yield put(signInSuccess(response));
} catch (e: any) {
yield put(
signInFailure({
error: e.message,
})
);
}
}
function* authSaga() {
yield all([takeLatest(SIGNIN_REQUEST, loginSaga)]);
}
export default authSaga;
我确实放了一些console.log,结果代码永远不会打印LOG1。我还尝试在登录功能中添加返回,如下所示:
const login = async (payload: SignInRequestPayload) => {
const { data } = await axios.post<SignInResponsePayload>(
(Config.test ? webapi.testUrl : webapi.prodUrl) + webapi.auth.signIn,
{
...payload,
}
);
console.info("LOG2");
return data;
};
但同样在这种情况下,LOG2 永远不会被打印。
如果我还添加我的后端,也许它会很有用docker-compose.yaml:
version: "3.8"
services:
reverse-proxy:
# The official v2 Traefik docker image
image: traefik:v2.2
# Enables the web UI and tells Traefik to listen to docker
command:
- "--api.insecure=true"
- "--providers.docker"
- "--providers.docker.exposedByDefault=false"
ports:
# The HTTP port
- "80:80"
# The Web UI (enabled by --api.insecure=true)
- "8080:8080"
volumes:
# So that Traefik can listen to the Docker events
- /var/run/docker.sock:/var/run/docker.sock
postgres-auth:
image: "postgres:alpine"
environment:
- POSTGRES_PASSWORD=password
ports:
- "5432:5432"
# Set a volume for data and initial sql script
# May configure initial db for future demo
volumes:
- "pgdata_auth:/var/lib/postgresql/data"
# - ./init:/docker-entrypoint-initdb.d/
command: ["postgres", "-c", "log_statement=all"]
redis-auth:
image: "redis:alpine"
ports:
- "6379:6379"
volumes:
- "redisdata_auth:/data"
apiserver:
build:
context: .
target: builder
image: apiserver
expose:
- "8080"
labels:
- "traefik.enable=true"
- "traefik.http.routers.apiserver.rule=Host(`laboratoriomister.test`) && PathPrefix(`/api/`)"
- "traefik.http.middlewares.cors.headers.accesscontrolalloworigin=*"
- "traefik.http.middlewares.cors.headers.accesscontrolallowmethods=GET,POST,PUT,DELETE,OPTIONS"
- "traefik.http.middlewares.cors.headers.accesscontrolallowheaders=Content-Type"
environment:
- ENV=dev
volumes:
- .:/app
# have to use $$ (double-dollar) so docker doesn't try to substitute a variable
command: reflex -r "\.go$$" -s -- sh -c "go run ./"
env_file: .env
depends_on:
- postgres-auth
- redis-auth
volumes:
pgdata_auth:
redisdata_auth:
如果您需要更多信息或代码片段,请告诉我。 预先感谢
“
context canceled
”错误应该意味着您在请求中使用的上下文在操作完成之前被取消。signIn
端点工作流程是:
[Client (Typescript)] --HTTP Request--> [Go Server] --Set Token--> [Redis]
检查Redis操作是否耗时过长,上下文超时。并确保传递给 Redis 操作的上下文不会提前取消。
func (s *tokenService) setRefreshTokenWithCtx(ctx context.Context, userID, tokenID string, expiresIn time.Duration) error {
// That will block until the token is set or the context is canceled
err := s.TokenRepository.SetRefreshToken(ctx, userID, tokenID, expiresIn)
if err != nil {
return fmt.Errorf("could not SET refresh token to redis for userID/tokenID: %s/%s: %v", userID, tokenID, err)
}
return nil
}
当您调用此函数时,传入具有合理截止日期或超时的上下文:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := setRefreshTokenWithCtx(ctx, u.UID.String(), refreshToken.ID, refreshToken.ExpiresIn)
if err != nil {
log.Printf("Error: %v\n", err)
// Handle the error, possibly by returning a specific error to the client
}
在 TypeScript 代码中,您需要处理响应和潜在的超时:
const login = async (payload: SignInRequestPayload): Promise<SignInResponsePayload> => {
try {
const response = await axios.post<SignInResponsePayload>(
`${Config.test ? webapi.testUrl : webapi.prodUrl}${webapi.auth.signIn}`,
payload,
{
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
timeout: 5000, // Set a timeout for the request
}
);
return response.data;
} catch (error) {
console.error("An error occurred during login:", error);
throw error;
}
};
最后,确保您的前端正确处理 CORS 和预检请求,因为如果处理不当,这也可能导致上下文被取消。如果预检请求由于 CORS 策略而未成功,则实际请求可能会被取消。请参阅 austincollinpena 的“ CORS 问题,这绝对要了我的命 - 我确定这是我缺少的基本配置问题!”作为说明:这意味着确保 Traefik 配置为正确处理 CORS 请求:
labels:
- "traefik.http.middlewares.myapp-cors.headers.accesscontrolallowmethods=GET,POST,PUT,DELETE,OPTIONS"
- "traefik.http.middlewares.myapp-cors.headers.accesscontrolallowheaders=*"
- "traefik.http.middlewares.myapp-cors.headers.accesscontrolalloworigin=*"
- "traefik.http.routers.myapp.middlewares=myapp-cors"