Insufficient Session Expiration in heroiclabs/nakama
Description
Old session tokens remain valid in Nakama, allowing attackers to authenticate and send requests; fixed by adding a logout endpoint.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Old session tokens remain valid in Nakama, allowing attackers to authenticate and send requests; fixed by adding a logout endpoint.
Vulnerability
CVE-2022-2306 describes a session token reuse vulnerability in the Nakama game server. The application fails to invalidate old session tokens, meaning tokens that have been used previously remain valid indefinitely. This allows an attacker who possesses an old token to authenticate to the server and perform authorized actions as if the token were still active [1].
Exploitation
An attacker can exploit this flaw by using an old session token to authenticate to the Nakama console API. No additional authentication is required if the token is already valid. The attacker can then send authenticated requests to the console, potentially accessing management functions or data [2].
Impact
Successful exploitation grants an attacker unauthorized access to the Nakama console, which could lead to exposure of sensitive data, manipulation of server configuration, or disruption of game services. The severity of the impact depends on the privileges associated with the compromised token [3].
Mitigation
Heroic Labs addressed this vulnerability in commit ce8d3921e2acd44ef8b5e6edfe595b6df067b166, which adds a /v2/console/authenticate/logout endpoint that invalidates session tokens upon logout [2]. The fix ensures that after a user logs out, the token can no longer be used for authentication. Users should update to a version containing this commit to protect against token reuse [3].
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/heroiclabs/nakamaGo | <= 3.12.0 | — |
Affected products
2- Range: unspecified
Patches
1ce8d3921e2acCorrectly console session token on all logouts. (#875)
16 files changed · +1528 −1207
console/console_grpc.pb.go+38 −0 modified@@ -22,6 +22,8 @@ const _ = grpc.SupportPackageIsVersion7 type ConsoleClient interface { // Authenticate a console user with username and password. Authenticate(ctx context.Context, in *AuthenticateRequest, opts ...grpc.CallOption) (*ConsoleSession, error) + // Log out a session and invalidate the session token. + AuthenticateLogout(ctx context.Context, in *AuthenticateLogoutRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) // Add a new console user. AddUser(ctx context.Context, in *AddUserRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) // Ban a user. @@ -153,6 +155,15 @@ func (c *consoleClient) Authenticate(ctx context.Context, in *AuthenticateReques return out, nil } +func (c *consoleClient) AuthenticateLogout(ctx context.Context, in *AuthenticateLogoutRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, "/nakama.console.Console/AuthenticateLogout", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *consoleClient) AddUser(ctx context.Context, in *AddUserRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { out := new(emptypb.Empty) err := c.cc.Invoke(ctx, "/nakama.console.Console/AddUser", in, out, opts...) @@ -663,6 +674,8 @@ func (c *consoleClient) WriteStorageObject(ctx context.Context, in *WriteStorage type ConsoleServer interface { // Authenticate a console user with username and password. Authenticate(context.Context, *AuthenticateRequest) (*ConsoleSession, error) + // Log out a session and invalidate the session token. + AuthenticateLogout(context.Context, *AuthenticateLogoutRequest) (*emptypb.Empty, error) // Add a new console user. AddUser(context.Context, *AddUserRequest) (*emptypb.Empty, error) // Ban a user. @@ -785,6 +798,9 @@ type UnimplementedConsoleServer struct { func (UnimplementedConsoleServer) Authenticate(context.Context, *AuthenticateRequest) (*ConsoleSession, error) { return nil, status.Errorf(codes.Unimplemented, "method Authenticate not implemented") } +func (UnimplementedConsoleServer) AuthenticateLogout(context.Context, *AuthenticateLogoutRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method AuthenticateLogout not implemented") +} func (UnimplementedConsoleServer) AddUser(context.Context, *AddUserRequest) (*emptypb.Empty, error) { return nil, status.Errorf(codes.Unimplemented, "method AddUser not implemented") } @@ -984,6 +1000,24 @@ func _Console_Authenticate_Handler(srv interface{}, ctx context.Context, dec fun return interceptor(ctx, in, info, handler) } +func _Console_AuthenticateLogout_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AuthenticateLogoutRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ConsoleServer).AuthenticateLogout(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/nakama.console.Console/AuthenticateLogout", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ConsoleServer).AuthenticateLogout(ctx, req.(*AuthenticateLogoutRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _Console_AddUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(AddUserRequest) if err := dec(in); err != nil { @@ -2003,6 +2037,10 @@ var Console_ServiceDesc = grpc.ServiceDesc{ MethodName: "Authenticate", Handler: _Console_Authenticate_Handler, }, + { + MethodName: "AuthenticateLogout", + Handler: _Console_AuthenticateLogout_Handler, + }, { MethodName: "AddUser", Handler: _Console_AddUser_Handler,
console/console.pb.go+1247 −1172 modifiedconsole/console.pb.gw.go+81 −0 modified@@ -67,6 +67,40 @@ func local_request_Console_Authenticate_0(ctx context.Context, marshaler runtime } +func request_Console_AuthenticateLogout_0(ctx context.Context, marshaler runtime.Marshaler, client ConsoleClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq AuthenticateLogoutRequest + var metadata runtime.ServerMetadata + + newReader, berr := utilities.IOReaderFactory(req.Body) + if berr != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", berr) + } + if err := marshaler.NewDecoder(newReader()).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := client.AuthenticateLogout(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func local_request_Console_AuthenticateLogout_0(ctx context.Context, marshaler runtime.Marshaler, server ConsoleServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq AuthenticateLogoutRequest + var metadata runtime.ServerMetadata + + newReader, berr := utilities.IOReaderFactory(req.Body) + if berr != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", berr) + } + if err := marshaler.NewDecoder(newReader()).Decode(&protoReq); err != nil && err != io.EOF { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := server.AuthenticateLogout(ctx, &protoReq) + return msg, metadata, err + +} + func request_Console_AddUser_0(ctx context.Context, marshaler runtime.Marshaler, client ConsoleClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { var protoReq AddUserRequest var metadata runtime.ServerMetadata @@ -3058,6 +3092,29 @@ func RegisterConsoleHandlerServer(ctx context.Context, mux *runtime.ServeMux, se }) + mux.Handle("POST", pattern_Console_AuthenticateLogout_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + rctx, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/nakama.console.Console/AuthenticateLogout", runtime.WithHTTPPathPattern("/v2/console/authenticate/logout")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_Console_AuthenticateLogout_0(rctx, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + ctx = runtime.NewServerMetadataContext(ctx, md) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + + forward_Console_AuthenticateLogout_0(ctx, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + mux.Handle("POST", pattern_Console_AddUser_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -4430,6 +4487,26 @@ func RegisterConsoleHandlerClient(ctx context.Context, mux *runtime.ServeMux, cl }) + mux.Handle("POST", pattern_Console_AuthenticateLogout_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + rctx, err := runtime.AnnotateContext(ctx, mux, req, "/nakama.console.Console/AuthenticateLogout", runtime.WithHTTPPathPattern("/v2/console/authenticate/logout")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_Console_AuthenticateLogout_0(rctx, inboundMarshaler, client, req, pathParams) + ctx = runtime.NewServerMetadataContext(ctx, md) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + + forward_Console_AuthenticateLogout_0(ctx, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + mux.Handle("POST", pattern_Console_AddUser_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { ctx, cancel := context.WithCancel(req.Context()) defer cancel() @@ -5576,6 +5653,8 @@ func RegisterConsoleHandlerClient(ctx context.Context, mux *runtime.ServeMux, cl var ( pattern_Console_Authenticate_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v2", "console", "authenticate"}, "")) + pattern_Console_AuthenticateLogout_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3}, []string{"v2", "console", "authenticate", "logout"}, "")) + pattern_Console_AddUser_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v2", "console", "user"}, "")) pattern_Console_BanAccount_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 1, 0, 4, 1, 5, 3, 2, 4}, []string{"v2", "console", "account", "id", "ban"}, "")) @@ -5694,6 +5773,8 @@ var ( var ( forward_Console_Authenticate_0 = runtime.ForwardResponseMessage + forward_Console_AuthenticateLogout_0 = runtime.ForwardResponseMessage + forward_Console_AddUser_0 = runtime.ForwardResponseMessage forward_Console_BanAccount_0 = runtime.ForwardResponseMessage
console/console.proto+14 −0 modified@@ -86,6 +86,14 @@ service Console { }; } + // Log out a session and invalidate the session token. + rpc AuthenticateLogout (AuthenticateLogoutRequest) returns (google.protobuf.Empty) { + option (google.api.http) = { + post: "/v2/console/authenticate/logout", + body: "*" + }; + } + // Add a new console user. rpc AddUser (AddUserRequest) returns (google.protobuf.Empty) { option (google.api.http) = { @@ -504,6 +512,12 @@ message AuthenticateRequest { string password = 2; } +// Log out a session and invalidate a session token. +message AuthenticateLogoutRequest { + // Session token to log out. + string token = 1; +} + // API Explorer request definition for CallApiEndpoint message CallApiEndpointRequest { string method = 1;
console/console.swagger.json+43 −0 modified@@ -1060,6 +1060,39 @@ ] } }, + "/v2/console/authenticate/logout": { + "post": { + "summary": "Log out a session and invalidate the session token.", + "operationId": "Console_AuthenticateLogout", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "properties": {} + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/googlerpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/consoleAuthenticateLogoutRequest" + } + } + ], + "tags": [ + "Console" + ] + } + }, "/v2/console/config": { "get": { "summary": "Get server config and configuration warnings.", @@ -2998,6 +3031,16 @@ }, "title": "API Explorer List of Endpoints" }, + "consoleAuthenticateLogoutRequest": { + "type": "object", + "properties": { + "token": { + "type": "string", + "description": "Session token to log out." + } + }, + "description": "Log out a session and invalidate a session token." + }, "consoleAuthenticateRequest": { "type": "object", "properties": {
console/ui/dist/index.html+2 −2 modified@@ -4,11 +4,11 @@ <meta charset="utf-8"> <title>Nakama Console</title> <base href="/"> - <style type="text/css">@font-face{font-family:'Montserrat';font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/montserrat/v18/JTURjIg1_i6t8kCHKm45_ZpC3gTD_vx3rCubqg.woff2) format('woff2');unicode-range:U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;}@font-face{font-family:'Montserrat';font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/montserrat/v18/JTURjIg1_i6t8kCHKm45_ZpC3g3D_vx3rCubqg.woff2) format('woff2');unicode-range:U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;}@font-face{font-family:'Montserrat';font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/montserrat/v18/JTURjIg1_i6t8kCHKm45_ZpC3gbD_vx3rCubqg.woff2) format('woff2');unicode-range:U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;}@font-face{font-family:'Montserrat';font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/montserrat/v18/JTURjIg1_i6t8kCHKm45_ZpC3gfD_vx3rCubqg.woff2) format('woff2');unicode-range:U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;}@font-face{font-family:'Montserrat';font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/montserrat/v18/JTURjIg1_i6t8kCHKm45_ZpC3gnD_vx3rCs.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;}@font-face{font-family:'Open Sans';font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/opensans/v27/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4taVQUwaEQbjB_mQ.woff) format('woff');unicode-range:U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;}@font-face{font-family:'Open Sans';font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/opensans/v27/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4kaVQUwaEQbjB_mQ.woff) format('woff');unicode-range:U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;}@font-face{font-family:'Open Sans';font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/opensans/v27/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4saVQUwaEQbjB_mQ.woff) format('woff');unicode-range:U+1F00-1FFF;}@font-face{font-family:'Open Sans';font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/opensans/v27/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4jaVQUwaEQbjB_mQ.woff) format('woff');unicode-range:U+0370-03FF;}@font-face{font-family:'Open Sans';font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/opensans/v27/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4iaVQUwaEQbjB_mQ.woff) format('woff');unicode-range:U+0590-05FF, U+20AA, U+25CC, U+FB1D-FB4F;}@font-face{font-family:'Open Sans';font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/opensans/v27/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4vaVQUwaEQbjB_mQ.woff) format('woff');unicode-range:U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;}@font-face{font-family:'Open Sans';font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/opensans/v27/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4uaVQUwaEQbjB_mQ.woff) format('woff');unicode-range:U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;}@font-face{font-family:'Open Sans';font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/opensans/v27/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4gaVQUwaEQbjA.woff) format('woff');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;}</style> + <style type="text/css">@font-face{font-family:'Montserrat';font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/montserrat/v24/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCtZ6Hw0aXx-p7K4KLjztg.woff) format('woff');unicode-range:U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;}@font-face{font-family:'Montserrat';font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/montserrat/v24/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCtZ6Hw9aXx-p7K4KLjztg.woff) format('woff');unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;}@font-face{font-family:'Montserrat';font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/montserrat/v24/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCtZ6Hw2aXx-p7K4KLjztg.woff) format('woff');unicode-range:U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;}@font-face{font-family:'Montserrat';font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/montserrat/v24/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCtZ6Hw3aXx-p7K4KLjztg.woff) format('woff');unicode-range:U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;}@font-face{font-family:'Montserrat';font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/montserrat/v24/JTUHjIg1_i6t8kCHKm4532VJOt5-QNFgpCtZ6Hw5aXx-p7K4KLg.woff) format('woff');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;}@font-face{font-family:'Open Sans';font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/opensans/v29/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4taVQUwaEQbjB_mQ.woff) format('woff');unicode-range:U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;}@font-face{font-family:'Open Sans';font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/opensans/v29/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4kaVQUwaEQbjB_mQ.woff) format('woff');unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;}@font-face{font-family:'Open Sans';font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/opensans/v29/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4saVQUwaEQbjB_mQ.woff) format('woff');unicode-range:U+1F00-1FFF;}@font-face{font-family:'Open Sans';font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/opensans/v29/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4jaVQUwaEQbjB_mQ.woff) format('woff');unicode-range:U+0370-03FF;}@font-face{font-family:'Open Sans';font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/opensans/v29/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4iaVQUwaEQbjB_mQ.woff) format('woff');unicode-range:U+0590-05FF, U+200C-2010, U+20AA, U+25CC, U+FB1D-FB4F;}@font-face{font-family:'Open Sans';font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/opensans/v29/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4vaVQUwaEQbjB_mQ.woff) format('woff');unicode-range:U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;}@font-face{font-family:'Open Sans';font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/opensans/v29/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4uaVQUwaEQbjB_mQ.woff) format('woff');unicode-range:U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;}@font-face{font-family:'Open Sans';font-style:normal;font-weight:400;font-stretch:100%;font-display:swap;src:url(https://fonts.gstatic.com/s/opensans/v29/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4gaVQUwaEQbjA.woff) format('woff');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;}</style> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="stylesheet" href="static/styles.14b882f135e080634619.css"></head> <body class="h-100"> <app-root></app-root> -<script src="static/runtime.4ced225923cd14368d64.js" defer=""></script><script src="static/polyfills.cb4331e883de4daa4c94.js" defer=""></script><script src="static/main.0b74ccf8d7e0caf7719b.js" defer=""></script></body> +<script src="static/runtime.4ced225923cd14368d64.js" defer=""></script><script src="static/polyfills.cb4331e883de4daa4c94.js" defer=""></script><script src="static/main.2778305943c5228ee227.js" defer=""></script></body> </html>
console/ui/dist/static/main.2778305943c5228ee227.js+1 −1 renamedconsole/ui/src/app/authentication-error.interceptor.ts+9 −7 modified@@ -29,13 +29,15 @@ export class AuthenticationErrorInterceptor implements HttpInterceptor { intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { return next.handle(req).pipe(catchError(err => { if (err.status === 401) { - this.authenticationService.logout(); - - if (!req.url.includes('/v3/auth')) { - // only reload the page if we aren't on the auth pages, this is so that we can display the auth errors. - const stateUrl = this.router.routerState.snapshot.url; - const _ = this.router.navigate(['/login'], {queryParams: {next: stateUrl}}); - } + this.authenticationService.logout().subscribe({ + next: () => { + if (!req.url.includes('/v3/auth')) { + // only reload the page if we aren't on the auth pages, this is so that we can display the auth errors. + const stateUrl = this.router.routerState.snapshot.url; + const _ = this.router.navigate(['/login'], {queryParams: {next: stateUrl}}); + } + } + }); } else if (err.status >= 500) { console.log(`${err.status}: + ${err.error.message || err.statusText}`); }
console/ui/src/app/authentication.service.ts+11 −5 modified@@ -14,7 +14,7 @@ import {Inject, Injectable} from '@angular/core'; import {HttpClient} from '@angular/common/http'; -import {BehaviorSubject, Observable} from 'rxjs'; +import {BehaviorSubject, EMPTY, Observable} from 'rxjs'; import {tap} from 'rxjs/operators'; import {ConsoleService, ConsoleSession, UserRole} from './console.service'; import {WINDOW} from './window.provider'; @@ -79,10 +79,16 @@ export class AuthenticationService { })); } - logout(): void { - localStorage.removeItem(SESSION_LOCALSTORAGE_KEY); - // @ts-ignore - this.currentSessionSubject.next(null); + logout(): Observable<any> { + if (!this.currentSessionSubject.getValue()) { + return EMPTY; + } + return this.consoleService.authenticateLogout('', { + token: this.currentSessionSubject.getValue()?.token, + }).pipe(tap(() => { + localStorage.removeItem(SESSION_LOCALSTORAGE_KEY); + this.currentSessionSubject.next(null); + })); } segmentIdentify(session): void {
console/ui/src/app/base/base.component.ts+3 −1 modified@@ -113,7 +113,9 @@ export class BaseComponent implements OnInit, OnDestroy { } logout(): void { - this.authService.logout(); + this.authService.logout().subscribe(() => { + this.router.navigate(['/login']); + }); } ngOnDestroy(): void {
console/ui/src/app/console.service.ts+13 −0 modified@@ -66,6 +66,12 @@ export interface ApiEndpointList { rpc_endpoints?:Array<ApiEndpointDescriptor> } +/** Log out a session and invalidate a session token. */ +export interface AuthenticateLogoutRequest { + // Session token to log out. + token?:string +} + /** Authenticate a console user with username and password. */ export interface AuthenticateRequest { // The password of the user. @@ -1013,6 +1019,13 @@ export class ConsoleService { return this.httpClient.post<ConsoleSession>(this.config.host + urlPath, body, { params: params }) } + /** Log out a session and invalidate the session token. */ + authenticateLogout(auth_token: string, body: AuthenticateLogoutRequest): Observable<any> { + const urlPath = `/v2/console/authenticate/logout`; + let params = new HttpParams(); + return this.httpClient.post(this.config.host + urlPath, body, { params: params, headers: this.getTokenAuthHeaders(auth_token) }) + } + /** Get server config and configuration warnings. */ getConfig(auth_token: string): Observable<Config> { const urlPath = `/v2/console/config`;
main.go+3 −2 modified@@ -139,7 +139,8 @@ func main() { cookie := newOrLoadCookie(config) metrics := server.NewLocalMetrics(logger, startupLogger, db, config) sessionRegistry := server.NewLocalSessionRegistry(metrics) - sessionCache := server.NewLocalSessionCache(config) + sessionCache := server.NewLocalSessionCache(config, config.GetSession().TokenExpirySec) + consoleSessionCache := server.NewLocalSessionCache(config, config.GetConsole().TokenExpirySec) statusRegistry := server.NewStatusRegistry(logger, config, sessionRegistry, jsonpbMarshaler) tracker := server.StartLocalTracker(logger, config, sessionRegistry, statusRegistry, metrics, jsonpbMarshaler) router := server.NewLocalMessageRouter(sessionRegistry, tracker, jsonpbMarshaler) @@ -165,7 +166,7 @@ func main() { statusHandler := server.NewLocalStatusHandler(logger, sessionRegistry, matchRegistry, tracker, metrics, config.GetName()) apiServer := server.StartApiServer(logger, startupLogger, db, jsonpbMarshaler, jsonpbUnmarshaler, config, socialClient, leaderboardCache, leaderboardRankCache, sessionRegistry, sessionCache, statusRegistry, matchRegistry, matchmaker, tracker, router, streamManager, metrics, pipeline, runtime) - consoleServer := server.StartConsoleServer(logger, startupLogger, db, config, tracker, router, streamManager, sessionCache, statusRegistry, statusHandler, runtimeInfo, matchRegistry, configWarnings, semver, leaderboardCache, leaderboardRankCache, apiServer, cookie) + consoleServer := server.StartConsoleServer(logger, startupLogger, db, config, tracker, router, streamManager, sessionCache, consoleSessionCache, statusRegistry, statusHandler, runtimeInfo, matchRegistry, configWarnings, semver, leaderboardCache, leaderboardRankCache, apiServer, cookie) gaenabled := len(os.Getenv("NAKAMA_TELEMETRY")) < 1 const gacode = "UA-89792135-1"
server/console_authenticate.go+38 −7 modified@@ -20,6 +20,8 @@ import ( "database/sql" "errors" "fmt" + "github.com/gofrs/uuid" + "google.golang.org/protobuf/types/known/emptypb" "time" jwt "github.com/golang-jwt/jwt/v4" @@ -32,6 +34,7 @@ import ( ) type ConsoleTokenClaims struct { + ID string `json:"id,omitempty"` Username string `json:"usn,omitempty"` Email string `json:"ema,omitempty"` Role console.UserRole `json:"rol,omitempty"` @@ -50,7 +53,7 @@ func (stc *ConsoleTokenClaims) Valid() error { return nil } -func parseConsoleToken(hmacSecretByte []byte, tokenString string) (username, email string, role console.UserRole, exp int64, ok bool) { +func parseConsoleToken(hmacSecretByte []byte, tokenString string) (id, username, email string, role console.UserRole, exp int64, ok bool) { token, err := jwt.ParseWithClaims(tokenString, &ConsoleTokenClaims{}, func(token *jwt.Token) (interface{}, error) { if s, ok := token.Method.(*jwt.SigningMethodHMAC); !ok || s.Hash != crypto.SHA256 { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) @@ -64,22 +67,24 @@ func parseConsoleToken(hmacSecretByte []byte, tokenString string) (username, ema if !ok || !token.Valid { return } - return claims.Username, claims.Email, claims.Role, claims.ExpiresAt, true + return claims.ID, claims.Username, claims.Email, claims.Role, claims.ExpiresAt, true } func (s *ConsoleServer) Authenticate(ctx context.Context, in *console.AuthenticateRequest) (*console.ConsoleSession, error) { role := console.UserRole_USER_ROLE_UNKNOWN var uname string var email string + var id uuid.UUID switch in.Username { case s.config.GetConsole().Username: if in.Password == s.config.GetConsole().Password { role = console.UserRole_USER_ROLE_ADMIN uname = in.Username + id = uuid.Nil } default: var err error - uname, email, role, err = s.lookupConsoleUser(ctx, in.Username, in.Password) + id, uname, email, role, err = s.lookupConsoleUser(ctx, in.Username, in.Password) if err != nil { return nil, err } @@ -89,24 +94,50 @@ func (s *ConsoleServer) Authenticate(ctx context.Context, in *console.Authentica return nil, status.Error(codes.Unauthenticated, "Invalid credentials.") } + exp := time.Now().UTC().Add(time.Duration(s.config.GetConsole().TokenExpirySec) * time.Second).Unix() token := jwt.NewWithClaims(jwt.SigningMethodHS256, &ConsoleTokenClaims{ - ExpiresAt: time.Now().UTC().Add(time.Duration(s.config.GetConsole().TokenExpirySec) * time.Second).Unix(), + ExpiresAt: exp, + ID: id.String(), Username: uname, Email: email, Role: role, Cookie: s.cookie, }) key := []byte(s.config.GetConsole().SigningKey) signedToken, _ := token.SignedString(key) + + s.consoleSessionCache.Add(id, exp, signedToken, 0, "") return &console.ConsoleSession{Token: signedToken}, nil } -func (s *ConsoleServer) lookupConsoleUser(ctx context.Context, unameOrEmail, password string) (uname string, email string, role console.UserRole, err error) { +func (s *ConsoleServer) AuthenticateLogout(ctx context.Context, in *console.AuthenticateLogoutRequest) (*emptypb.Empty, error) { + token, err := jwt.Parse(in.Token, func(token *jwt.Token) (interface{}, error) { + if s, ok := token.Method.(*jwt.SigningMethodHMAC); !ok || s.Hash != crypto.SHA256 { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(s.config.GetConsole().SigningKey), nil + }) + if err != nil { + s.logger.Error("Failed to parse the session token.", zap.Error(err)) + } + id, _, _, _, exp, ok := parseConsoleToken([]byte(s.config.GetConsole().SigningKey), in.Token) + if !ok || !token.Valid { + s.logger.Error("Invalid token.", zap.Error(err)) + } + idUuid, err := uuid.FromString(id) + if id != "" && err == nil { + s.consoleSessionCache.Remove(idUuid, exp, in.Token, 0, "") + } + + return &emptypb.Empty{}, nil +} + +func (s *ConsoleServer) lookupConsoleUser(ctx context.Context, unameOrEmail, password string) (id uuid.UUID, uname string, email string, role console.UserRole, err error) { role = console.UserRole_USER_ROLE_UNKNOWN - query := "SELECT username, email, role, password, disable_time FROM console_user WHERE username = $1 OR email = $1" + query := "SELECT id, username, email, role, password, disable_time FROM console_user WHERE username = $1 OR email = $1" var dbPassword []byte var dbDisableTime pgtype.Timestamptz - err = s.db.QueryRowContext(ctx, query, unameOrEmail).Scan(&uname, &email, &role, &dbPassword, &dbDisableTime) + err = s.db.QueryRowContext(ctx, query, unameOrEmail).Scan(&id, &uname, &email, &role, &dbPassword, &dbDisableTime) if err != nil { if err == sql.ErrNoRows { err = nil
server/console.go+22 −7 modified@@ -19,6 +19,7 @@ import ( "crypto" "database/sql" "fmt" + "github.com/gofrs/uuid" "io/ioutil" "math" "net" @@ -136,6 +137,7 @@ type ConsoleServer struct { router MessageRouter StreamManager StreamManager sessionCache SessionCache + consoleSessionCache SessionCache statusRegistry *StatusRegistry matchRegistry MatchRegistry statusHandler StatusHandler @@ -153,7 +155,7 @@ type ConsoleServer struct { httpClient *http.Client } -func StartConsoleServer(logger *zap.Logger, startupLogger *zap.Logger, db *sql.DB, config Config, tracker Tracker, router MessageRouter, streamManager StreamManager, sessionCache SessionCache, statusRegistry *StatusRegistry, statusHandler StatusHandler, runtimeInfo *RuntimeInfo, matchRegistry MatchRegistry, configWarnings map[string]string, serverVersion string, leaderboardCache LeaderboardCache, leaderboardRankCache LeaderboardRankCache, api *ApiServer, cookie string) *ConsoleServer { +func StartConsoleServer(logger *zap.Logger, startupLogger *zap.Logger, db *sql.DB, config Config, tracker Tracker, router MessageRouter, streamManager StreamManager, sessionCache SessionCache, consoleSessionCache SessionCache, statusRegistry *StatusRegistry, statusHandler StatusHandler, runtimeInfo *RuntimeInfo, matchRegistry MatchRegistry, configWarnings map[string]string, serverVersion string, leaderboardCache LeaderboardCache, leaderboardRankCache LeaderboardRankCache, api *ApiServer, cookie string) *ConsoleServer { var gatewayContextTimeoutMs string if config.GetConsole().IdleTimeoutMs > 500 { // Ensure the GRPC Gateway timeout is just under the idle timeout (if possible) to ensure it has priority. @@ -165,7 +167,7 @@ func StartConsoleServer(logger *zap.Logger, startupLogger *zap.Logger, db *sql.D serverOpts := []grpc.ServerOption{ //grpc.StatsHandler(&ocgrpc.ServerHandler{IsPublicEndpoint: true}), grpc.MaxRecvMsgSize(int(config.GetConsole().MaxMessageSizeBytes)), - grpc.UnaryInterceptor(consoleInterceptorFunc(logger, config)), + grpc.UnaryInterceptor(consoleInterceptorFunc(logger, config, consoleSessionCache)), } grpcServer := grpc.NewServer(serverOpts...) @@ -179,6 +181,7 @@ func StartConsoleServer(logger *zap.Logger, startupLogger *zap.Logger, db *sql.D router: router, StreamManager: streamManager, sessionCache: sessionCache, + consoleSessionCache: consoleSessionCache, statusRegistry: statusRegistry, matchRegistry: matchRegistry, statusHandler: statusHandler, @@ -423,12 +426,15 @@ func (s *ConsoleServer) Stop() { s.grpcServer.GracefulStop() } -func consoleInterceptorFunc(logger *zap.Logger, config Config) func(context.Context, interface{}, *grpc.UnaryServerInfo, grpc.UnaryHandler) (interface{}, error) { +func consoleInterceptorFunc(logger *zap.Logger, config Config, sessionCache SessionCache) func(context.Context, interface{}, *grpc.UnaryServerInfo, grpc.UnaryHandler) (interface{}, error) { return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { if info.FullMethod == "/nakama.console.Console/Authenticate" { // Skip authentication check for Login endpoint. return handler(ctx, req) } + if info.FullMethod == "/nakama.console.Console/AuthenticateLogout" { + return handler(ctx, req) + } md, ok := metadata.FromIncomingContext(ctx) if !ok { @@ -446,7 +452,7 @@ func consoleInterceptorFunc(logger *zap.Logger, config Config) func(context.Cont return nil, status.Error(codes.Unauthenticated, "Console authentication required.") } - if ctx, ok = checkAuth(ctx, config, auth[0]); !ok { + if ctx, ok = checkAuth(ctx, config, auth[0], sessionCache); !ok { return nil, status.Error(codes.Unauthenticated, "Console authentication invalid.") } role := ctx.Value(ctxConsoleRoleKey{}).(console.UserRole) @@ -460,7 +466,7 @@ func consoleInterceptorFunc(logger *zap.Logger, config Config) func(context.Cont } } -func checkAuth(ctx context.Context, config Config, auth string) (context.Context, bool) { +func checkAuth(ctx context.Context, config Config, auth string, sessionCache SessionCache) (context.Context, bool) { const basicPrefix = "Basic " const bearerPrefix = "Bearer " @@ -481,7 +487,8 @@ func checkAuth(ctx context.Context, config Config, auth string) (context.Context return ctx, true } else if strings.HasPrefix(auth, bearerPrefix) { // Bearer token authentication. - token, err := jwt.Parse(auth[len(bearerPrefix):], func(token *jwt.Token) (interface{}, error) { + tokenStr := auth[len(bearerPrefix):] + token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { if s, ok := token.Method.(*jwt.SigningMethodHMAC); !ok || s.Hash != crypto.SHA256 { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } @@ -491,7 +498,7 @@ func checkAuth(ctx context.Context, config Config, auth string) (context.Context // Token verification failed. return ctx, false } - uname, email, role, exp, ok := parseConsoleToken([]byte(config.GetConsole().SigningKey), auth[len(bearerPrefix):]) + id, uname, email, role, exp, ok := parseConsoleToken([]byte(config.GetConsole().SigningKey), tokenStr) if !ok || !token.Valid { // The token or its claims are invalid. return ctx, false @@ -504,6 +511,14 @@ func checkAuth(ctx context.Context, config Config, auth string) (context.Context // Token expired. return ctx, false } + userId, err := uuid.FromString(id) + if err != nil { + // Malformed id + return ctx, false + } + if !sessionCache.IsValidSession(userId, exp, tokenStr) { + return ctx, false + } ctx = context.WithValue(context.WithValue(context.WithValue(ctx, ctxConsoleRoleKey{}, role), ctxConsoleUsernameKey{}, uname), ctxConsoleEmailKey{}, email)
server/console_storage_import.go+1 −1 modified@@ -55,7 +55,7 @@ func (s *ConsoleServer) importStorage(w http.ResponseWriter, r *http.Request) { } return } - ctx, ok := checkAuth(r.Context(), s.config, auth) + ctx, ok := checkAuth(r.Context(), s.config, auth, s.consoleSessionCache) if !ok { w.WriteHeader(401) if _, err := w.Write([]byte("Console authentication invalid.")); err != nil {
server/session_cache.go+2 −2 modified@@ -56,7 +56,7 @@ type LocalSessionCache struct { cache map[uuid.UUID]*sessionCacheUser } -func NewLocalSessionCache(config Config) SessionCache { +func NewLocalSessionCache(config Config, tokenExpirySec int64) SessionCache { ctx, ctxCancelFn := context.WithCancel(context.Background()) s := &LocalSessionCache{ @@ -69,7 +69,7 @@ func NewLocalSessionCache(config Config) SessionCache { } go func() { - ticker := time.NewTicker(2 * time.Duration(config.GetSession().TokenExpirySec) * time.Second) + ticker := time.NewTicker(2 * time.Duration(tokenExpirySec) * time.Second) for { select { case <-s.ctx.Done():
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-xv59-gc3r-rf92ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2022-2306ghsaADVISORY
- github.com/heroiclabs/nakama/commit/ce8d3921e2acd44ef8b5e6edfe595b6df067b166ghsax_refsource_MISCWEB
- huntr.dev/bounties/35acf263-6db4-4310-ab27-4c3c3a53f796ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.