Skip to content

Commit e2e02fc

Browse files
authored
Better limit for unsuccessful login attempts on the devconsole. (heroiclabs#878)
1 parent 5666361 commit e2e02fc

File tree

4 files changed

+220
-7
lines changed

4 files changed

+220
-7
lines changed

main.go

+3-1
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ func main() {
141141
sessionRegistry := server.NewLocalSessionRegistry(metrics)
142142
sessionCache := server.NewLocalSessionCache(config.GetSession().TokenExpirySec)
143143
consoleSessionCache := server.NewLocalSessionCache(config.GetConsole().TokenExpirySec)
144+
loginAttemptCache := server.NewLocalLoginAttemptCache()
144145
statusRegistry := server.NewStatusRegistry(logger, config, sessionRegistry, jsonpbMarshaler)
145146
tracker := server.StartLocalTracker(logger, config, sessionRegistry, statusRegistry, metrics, jsonpbMarshaler)
146147
router := server.NewLocalMessageRouter(sessionRegistry, tracker, jsonpbMarshaler)
@@ -166,7 +167,7 @@ func main() {
166167
statusHandler := server.NewLocalStatusHandler(logger, sessionRegistry, matchRegistry, tracker, metrics, config.GetName())
167168

168169
apiServer := server.StartApiServer(logger, startupLogger, db, jsonpbMarshaler, jsonpbUnmarshaler, config, socialClient, leaderboardCache, leaderboardRankCache, sessionRegistry, sessionCache, statusRegistry, matchRegistry, matchmaker, tracker, router, streamManager, metrics, pipeline, runtime)
169-
consoleServer := server.StartConsoleServer(logger, startupLogger, db, config, tracker, router, streamManager, sessionCache, consoleSessionCache, statusRegistry, statusHandler, runtimeInfo, matchRegistry, configWarnings, semver, leaderboardCache, leaderboardRankCache, apiServer, cookie)
170+
consoleServer := server.StartConsoleServer(logger, startupLogger, db, config, tracker, router, streamManager, sessionCache, consoleSessionCache, loginAttemptCache, statusRegistry, statusHandler, runtimeInfo, matchRegistry, configWarnings, semver, leaderboardCache, leaderboardRankCache, apiServer, cookie)
170171

171172
gaenabled := len(os.Getenv("NAKAMA_TELEMETRY")) < 1
172173
const gacode = "UA-89792135-1"
@@ -232,6 +233,7 @@ func main() {
232233
sessionCache.Stop()
233234
sessionRegistry.Stop()
234235
metrics.Stop(logger)
236+
loginAttemptCache.Stop()
235237

236238
if gaenabled {
237239
_ = ga.SendSessionStop(telemetryClient, gacode, cookie)

server/console.go

+3-1
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ type ConsoleServer struct {
138138
StreamManager StreamManager
139139
sessionCache SessionCache
140140
consoleSessionCache SessionCache
141+
loginAttemptCache LoginAttemptCache
141142
statusRegistry *StatusRegistry
142143
matchRegistry MatchRegistry
143144
statusHandler StatusHandler
@@ -155,7 +156,7 @@ type ConsoleServer struct {
155156
httpClient *http.Client
156157
}
157158

158-
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 {
159+
func StartConsoleServer(logger *zap.Logger, startupLogger *zap.Logger, db *sql.DB, config Config, tracker Tracker, router MessageRouter, streamManager StreamManager, sessionCache SessionCache, consoleSessionCache SessionCache, loginAttemptCache LoginAttemptCache, statusRegistry *StatusRegistry, statusHandler StatusHandler, runtimeInfo *RuntimeInfo, matchRegistry MatchRegistry, configWarnings map[string]string, serverVersion string, leaderboardCache LeaderboardCache, leaderboardRankCache LeaderboardRankCache, api *ApiServer, cookie string) *ConsoleServer {
159160
var gatewayContextTimeoutMs string
160161
if config.GetConsole().IdleTimeoutMs > 500 {
161162
// Ensure the GRPC Gateway timeout is just under the idle timeout (if possible) to ensure it has priority.
@@ -182,6 +183,7 @@ func StartConsoleServer(logger *zap.Logger, startupLogger *zap.Logger, db *sql.D
182183
StreamManager: streamManager,
183184
sessionCache: sessionCache,
184185
consoleSessionCache: consoleSessionCache,
186+
loginAttemptCache: loginAttemptCache,
185187
statusRegistry: statusRegistry,
186188
matchRegistry: matchRegistry,
187189
statusHandler: statusHandler,

server/console_authenticate.go

+40-5
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,17 @@ import (
2020
"database/sql"
2121
"errors"
2222
"fmt"
23-
"github.com/gofrs/uuid"
24-
"google.golang.org/protobuf/types/known/emptypb"
2523
"time"
2624

25+
"github.com/gofrs/uuid"
2726
jwt "github.com/golang-jwt/jwt/v4"
2827
"github.com/heroiclabs/nakama/v3/console"
2928
"github.com/jackc/pgtype"
3029
"go.uber.org/zap"
3130
"golang.org/x/crypto/bcrypt"
3231
"google.golang.org/grpc/codes"
3332
"google.golang.org/grpc/status"
33+
"google.golang.org/protobuf/types/known/emptypb"
3434
)
3535

3636
type ConsoleTokenClaims struct {
@@ -71,6 +71,11 @@ func parseConsoleToken(hmacSecretByte []byte, tokenString string) (id, username,
7171
}
7272

7373
func (s *ConsoleServer) Authenticate(ctx context.Context, in *console.AuthenticateRequest) (*console.ConsoleSession, error) {
74+
ip, _ := extractClientAddressFromContext(s.logger, ctx)
75+
if !s.loginAttemptCache.Allow(in.Username, ip) {
76+
return nil, status.Error(codes.ResourceExhausted, "Try again later.")
77+
}
78+
7479
role := console.UserRole_USER_ROLE_UNKNOWN
7580
var uname string
7681
var email string
@@ -81,10 +86,20 @@ func (s *ConsoleServer) Authenticate(ctx context.Context, in *console.Authentica
8186
role = console.UserRole_USER_ROLE_ADMIN
8287
uname = in.Username
8388
id = uuid.Nil
89+
} else {
90+
if lockout, until := s.loginAttemptCache.Add(s.config.GetConsole().Username, ip); lockout != LockoutTypeNone {
91+
switch lockout {
92+
case LockoutTypeAccount:
93+
s.logger.Info(fmt.Sprintf("Console admin account locked until %v.", until))
94+
case LockoutTypeIp:
95+
s.logger.Info(fmt.Sprintf("Console admin IP locked until %v.", until))
96+
}
97+
}
98+
return nil, status.Error(codes.Unauthenticated, "Invalid credentials.")
8499
}
85100
default:
86101
var err error
87-
id, uname, email, role, err = s.lookupConsoleUser(ctx, in.Username, in.Password)
102+
id, uname, email, role, err = s.lookupConsoleUser(ctx, in.Username, in.Password, ip)
88103
if err != nil {
89104
return nil, err
90105
}
@@ -94,7 +109,10 @@ func (s *ConsoleServer) Authenticate(ctx context.Context, in *console.Authentica
94109
return nil, status.Error(codes.Unauthenticated, "Invalid credentials.")
95110
}
96111

112+
s.loginAttemptCache.Reset(uname)
113+
97114
exp := time.Now().UTC().Add(time.Duration(s.config.GetConsole().TokenExpirySec) * time.Second).Unix()
115+
98116
token := jwt.NewWithClaims(jwt.SigningMethodHS256, &ConsoleTokenClaims{
99117
ExpiresAt: exp,
100118
ID: id.String(),
@@ -132,19 +150,28 @@ func (s *ConsoleServer) AuthenticateLogout(ctx context.Context, in *console.Auth
132150
return &emptypb.Empty{}, nil
133151
}
134152

135-
func (s *ConsoleServer) lookupConsoleUser(ctx context.Context, unameOrEmail, password string) (id uuid.UUID, uname string, email string, role console.UserRole, err error) {
153+
func (s *ConsoleServer) lookupConsoleUser(ctx context.Context, unameOrEmail, password, ip string) (id uuid.UUID, uname string, email string, role console.UserRole, err error) {
136154
role = console.UserRole_USER_ROLE_UNKNOWN
137155
query := "SELECT id, username, email, role, password, disable_time FROM console_user WHERE username = $1 OR email = $1"
138156
var dbPassword []byte
139157
var dbDisableTime pgtype.Timestamptz
140158
err = s.db.QueryRowContext(ctx, query, unameOrEmail).Scan(&id, &uname, &email, &role, &dbPassword, &dbDisableTime)
141159
if err != nil {
142160
if err == sql.ErrNoRows {
143-
err = nil
161+
if lockout, until := s.loginAttemptCache.Add("", ip); lockout == LockoutTypeIp {
162+
s.logger.Info(fmt.Sprintf("Console user IP locked until %v.", until))
163+
}
164+
err = status.Error(codes.Unauthenticated, "Invalid credentials.")
144165
}
145166
return
146167
}
147168

169+
// Check lockout again as the login attempt may have been through email.
170+
if !s.loginAttemptCache.Allow(uname, ip) {
171+
err = status.Error(codes.ResourceExhausted, "Try again later.")
172+
return
173+
}
174+
148175
// Check if it's disabled.
149176
if dbDisableTime.Status == pgtype.Present && dbDisableTime.Time.Unix() != 0 {
150177
s.logger.Info("Console user account is disabled.", zap.String("username", unameOrEmail))
@@ -155,6 +182,14 @@ func (s *ConsoleServer) lookupConsoleUser(ctx context.Context, unameOrEmail, pas
155182
// Check password
156183
err = bcrypt.CompareHashAndPassword(dbPassword, []byte(password))
157184
if err != nil {
185+
if lockout, until := s.loginAttemptCache.Add(uname, ip); lockout != LockoutTypeNone {
186+
switch lockout {
187+
case LockoutTypeAccount:
188+
s.logger.Info(fmt.Sprintf("Console user account locked until %v.", until))
189+
case LockoutTypeIp:
190+
s.logger.Info(fmt.Sprintf("Console user IP locked until %v.", until))
191+
}
192+
}
158193
err = status.Error(codes.Unauthenticated, "Invalid credentials.")
159194
return
160195
}

server/login_attempt_cache.go

+174
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// Copyright 2022 The Nakama Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package server
16+
17+
import (
18+
"context"
19+
"sync"
20+
"time"
21+
)
22+
23+
type LockoutType uint8
24+
25+
const (
26+
LockoutTypeNone LockoutType = iota
27+
LockoutTypeAccount
28+
LockoutTypeIp
29+
)
30+
31+
const (
32+
maxAttemptsAccount = 5
33+
lockoutPeriodAccount = time.Minute * 1
34+
35+
maxAttemptsIp = 10
36+
lockoutPeriodIp = time.Minute * 10
37+
)
38+
39+
type LoginAttemptCache interface {
40+
Stop()
41+
// Allow checks whether account or IP is locked out or should be allowed to attempt to authenticate.
42+
Allow(account, ip string) bool
43+
// Add a failed attempt and return current lockout status.
44+
Add(account, ip string) (LockoutType, time.Time)
45+
// Reset account attempts on successful login.
46+
Reset(account string)
47+
}
48+
49+
type lockoutStatus struct {
50+
lockedUntil time.Time
51+
attempts []time.Time
52+
}
53+
54+
func (ls *lockoutStatus) trim(now time.Time, retentionPeriod time.Duration) bool {
55+
if ls.lockedUntil.Before(now) {
56+
ls.lockedUntil = time.Time{}
57+
}
58+
for i := len(ls.attempts) - 1; i >= 0; i-- {
59+
if now.Sub(ls.attempts[i]) >= retentionPeriod {
60+
ls.attempts = ls.attempts[i+1:]
61+
break
62+
}
63+
}
64+
65+
return ls.lockedUntil.IsZero() && len(ls.attempts) == 0
66+
}
67+
68+
type LocalLoginAttemptCache struct {
69+
sync.RWMutex
70+
ctx context.Context
71+
ctxCancelFn context.CancelFunc
72+
73+
accountCache map[string]*lockoutStatus
74+
ipCache map[string]*lockoutStatus
75+
}
76+
77+
func NewLocalLoginAttemptCache() LoginAttemptCache {
78+
ctx, ctxCancelFn := context.WithCancel(context.Background())
79+
80+
c := &LocalLoginAttemptCache{
81+
accountCache: make(map[string]*lockoutStatus),
82+
ipCache: make(map[string]*lockoutStatus),
83+
84+
ctx: ctx,
85+
ctxCancelFn: ctxCancelFn,
86+
}
87+
88+
go func() {
89+
ticker := time.NewTicker(10 * time.Minute)
90+
for {
91+
select {
92+
case <-c.ctx.Done():
93+
ticker.Stop()
94+
return
95+
case t := <-ticker.C:
96+
now := t.UTC()
97+
c.Lock()
98+
for account, status := range c.accountCache {
99+
if status.trim(now, lockoutPeriodAccount) {
100+
delete(c.accountCache, account)
101+
}
102+
}
103+
for ip, status := range c.ipCache {
104+
if status.trim(now, lockoutPeriodIp) {
105+
delete(c.ipCache, ip)
106+
}
107+
}
108+
c.Unlock()
109+
}
110+
}
111+
}()
112+
113+
return c
114+
}
115+
116+
func (c *LocalLoginAttemptCache) Stop() {
117+
c.ctxCancelFn()
118+
}
119+
120+
func (c *LocalLoginAttemptCache) Allow(account, ip string) bool {
121+
now := time.Now().UTC()
122+
c.RLock()
123+
defer c.RUnlock()
124+
if status, found := c.accountCache[account]; found && !status.lockedUntil.IsZero() && status.lockedUntil.After(now) {
125+
return false
126+
}
127+
if status, found := c.ipCache[ip]; found && !status.lockedUntil.IsZero() && status.lockedUntil.After(now) {
128+
return false
129+
}
130+
return true
131+
}
132+
133+
func (c *LocalLoginAttemptCache) Reset(account string) {
134+
c.Lock()
135+
delete(c.accountCache, account)
136+
c.Unlock()
137+
}
138+
139+
func (c *LocalLoginAttemptCache) Add(account, ip string) (LockoutType, time.Time) {
140+
now := time.Now().UTC()
141+
var lockoutType LockoutType
142+
var lockedUntil time.Time
143+
c.Lock()
144+
defer c.Unlock()
145+
if account != "" {
146+
status, found := c.accountCache[account]
147+
if !found {
148+
status = &lockoutStatus{}
149+
c.accountCache[account] = status
150+
}
151+
status.attempts = append(status.attempts, now)
152+
_ = status.trim(now, lockoutPeriodAccount)
153+
if len(status.attempts) >= maxAttemptsAccount {
154+
status.lockedUntil = now.Add(lockoutPeriodAccount)
155+
lockedUntil = status.lockedUntil
156+
lockoutType = LockoutTypeAccount
157+
}
158+
}
159+
if ip != "" {
160+
status, found := c.ipCache[ip]
161+
if !found {
162+
status = &lockoutStatus{}
163+
c.ipCache[ip] = status
164+
}
165+
status.attempts = append(status.attempts, now)
166+
_ = status.trim(now, lockoutPeriodIp)
167+
if len(status.attempts) >= maxAttemptsIp {
168+
status.lockedUntil = now.Add(lockoutPeriodIp)
169+
lockedUntil = status.lockedUntil
170+
lockoutType = LockoutTypeIp
171+
}
172+
}
173+
return lockoutType, lockedUntil
174+
}

0 commit comments

Comments
 (0)