Skip to content

Commit ef3d539

Browse files
committed
refactor: harden authentication and file access security
1 parent 6421655 commit ef3d539

13 files changed

Lines changed: 206 additions & 103 deletions

File tree

internal/dao/action/sys_users.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,14 +97,17 @@ func (h *Handler) SysUserCreate(username, password string, isAdmin bool) (string
9797
IsAdmin: strconv.FormatBool(isAdmin),
9898
Username: username,
9999
}
100-
user.HashedPassword = user.GetHashedPassword(password)
100+
hashedPassword, err := user.GetHashedPassword(password)
101+
if err != nil {
102+
return "", fmt.Errorf("failed to encode password: %w", err)
103+
}
104+
user.HashedPassword = hashedPassword
101105
user.UserId = user.NewUserId()
102106

103-
err := h.daoObj.Database.
107+
if err = h.daoObj.Database.
104108
Table(user.GetName(h.daoObj.GetPrefix())).
105109
Save(&user).
106-
Error
107-
if err != nil {
110+
Error; err != nil {
108111
return "", fmt.Errorf("failed to create user: %w", err)
109112
}
110113

internal/dao/model/sys_users.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,9 @@ func (t *SysUser) NewUserId() string {
5252
return id.Generate().String()
5353
}
5454

55-
func (t *SysUser) GetHashedPassword(password string) string {
56-
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
57-
return string(hashedPassword)
55+
func (t *SysUser) GetHashedPassword(password string) (string, error) {
56+
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
57+
return string(hashedPassword), err
5858
}
5959

6060
func (t *SysUser) IsPasswordCorrect(password string) bool {
Lines changed: 5 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,14 @@
11
package auth_jwt
22

33
import (
4-
"errors"
4+
"crypto/rand"
55
"fmt"
6-
"net"
7-
"os"
8-
"runtime"
9-
"strings"
10-
11-
"github.com/anyshake/observer/pkg/system"
12-
"github.com/google/uuid"
13-
"github.com/samber/lo"
14-
"github.com/shirou/gopsutil/v4/disk"
15-
"github.com/shirou/gopsutil/v4/mem"
166
)
177

188
func createJwtSecret() ([]byte, error) {
19-
if interfaces, err := net.Interfaces(); len(interfaces) > 0 && err == nil {
20-
macAddrArr := lo.FilterMap(interfaces, func(iface net.Interface, _ int) (string, bool) {
21-
if iface.Flags&net.FlagLoopback != 0 || len(iface.HardwareAddr) == 0 {
22-
return "", false
23-
}
24-
return iface.HardwareAddr.String(), true
25-
})
26-
if len(macAddrArr) > 0 {
27-
secret := uuid.NewSHA1(uuid.NameSpaceOID, []byte(strings.Join(macAddrArr, ";"))).String()
28-
return []byte(secret), nil
29-
}
30-
}
31-
32-
if hostname, err := os.Hostname(); len(hostname) > 0 && err == nil {
33-
cpuModel, err := system.GetCpuModel()
34-
if err != nil {
35-
return nil, fmt.Errorf("failed to get CPU model when creating JWT secret: %w", err)
36-
}
37-
memUsage, err := mem.VirtualMemory()
38-
if err != nil {
39-
return nil, fmt.Errorf("failed to get memory usage when creating JWT secret: %w", err)
40-
}
41-
cwd, err := os.Getwd()
42-
if err != nil {
43-
return nil, fmt.Errorf("failed to get current executable path when creating JWT secret: %w", err)
44-
}
45-
diskObj, err := disk.Usage(cwd)
46-
if err != nil {
47-
return nil, fmt.Errorf("failed to get disk usage when creating JWT secret: %w", err)
48-
}
49-
secretSeed := fmt.Sprintf(
50-
"%s/%s on %s, CPU model %s, RAM size %d bytes, disk size %d bytes, current run path %s",
51-
runtime.GOOS,
52-
runtime.GOARCH,
53-
hostname,
54-
cpuModel,
55-
memUsage.Total,
56-
diskObj.Total,
57-
cwd,
58-
)
59-
secret := uuid.NewSHA1(uuid.NameSpaceOID, []byte(secretSeed)).String()
60-
return []byte(secret), nil
9+
secret := make([]byte, 32)
10+
if _, err := rand.Read(secret); err != nil {
11+
return nil, fmt.Errorf("failed to generate random JWT secret: %w", err)
6112
}
62-
63-
return nil, errors.New("no suitable secret seed found")
13+
return secret, nil
6414
}

internal/server/router/auth/challenge.go

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ package auth
22

33
import (
44
"crypto/rand"
5+
"crypto/sha512"
6+
"crypto/subtle"
7+
"encoding/hex"
58
"math/big"
9+
"strconv"
10+
"strings"
611
"time"
712
)
813

@@ -33,8 +38,14 @@ func (la *authChallenge) getChallengeId() string {
3338
length = 20
3439
)
3540
b := make([]byte, length)
36-
for i := 0; i < length; i++ {
37-
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
41+
for i := range length {
42+
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
43+
if err != nil {
44+
var rb [1]byte
45+
_, _ = rand.Read(rb[:])
46+
b[i] = chars[int(rb[0])%len(chars)]
47+
continue
48+
}
3849
b[i] = chars[n.Int64()]
3950
}
4051

@@ -45,12 +56,42 @@ func (la *authChallenge) isChallengeAlive() bool {
4556
return time.Now().Before(la.createdAt.Add(la.ttl))
4657
}
4758

48-
func (la *authChallenge) verifyChallenge(id, solution string) bool {
49-
for i := byte(0); i < la.seed[0]; i++ {
50-
if solution[i] != '0' {
51-
return false
52-
}
59+
func (la *authChallenge) verifyChallenge(solution string) bool {
60+
if len(la.seed) < 2 {
61+
return false
62+
}
63+
64+
difficulty := int(la.seed[0])
65+
if difficulty < 0 || difficulty > hex.EncodedLen(sha512.Size) {
66+
return false
67+
}
68+
69+
nonceStr, hashHex, ok := strings.Cut(solution, ":")
70+
if !ok || nonceStr == "" || hashHex == "" {
71+
return false
72+
}
73+
if _, err := strconv.ParseUint(nonceStr, 10, 64); err != nil {
74+
return false
75+
}
76+
77+
expected, err := hex.DecodeString(hashHex)
78+
if err != nil || len(expected) != sha512.Size {
79+
return false
80+
}
81+
82+
challenge := la.seed[1:]
83+
var textBuilder strings.Builder
84+
textBuilder.Grow(len(challenge) + len(nonceStr))
85+
for _, b := range challenge {
86+
textBuilder.WriteRune(rune(b))
87+
}
88+
textBuilder.WriteString(nonceStr)
89+
90+
sum := sha512.Sum512([]byte(textBuilder.String()))
91+
if subtle.ConstantTimeCompare(expected, sum[:]) != 1 {
92+
return false
5393
}
5494

55-
return true
95+
actualHex := hex.EncodeToString(sum[:])
96+
return strings.HasPrefix(actualHex, strings.Repeat("0", difficulty))
5697
}

internal/server/router/auth/login.go

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,95 +4,112 @@ import (
44
"encoding/base64"
55
"encoding/json"
66
"errors"
7-
"fmt"
87
"net/http"
98
"time"
109

10+
"github.com/anyshake/observer/pkg/logger"
1111
"github.com/dchest/captcha"
1212
)
1313

14+
var (
15+
errInvalidLoginRequest = errors.New("invalid login request")
16+
errAuthenticationFailed = errors.New("authentication failed")
17+
)
18+
1419
func (h *auth) login(sessionId, secret, nonce, challengeId, challengeSolution, captchaId, captchaVal, payload, userAgent, userIp string) (code int, userId string, err error) {
20+
fail := func(status int, public error, internal string, args ...any) (int, string, error) {
21+
if internal != "" {
22+
logger.GetLogger(LOG_PREFIX).Errorf(internal, args...)
23+
}
24+
return status, "", public
25+
}
26+
1527
if sessionId == "" || secret == "" || nonce == "" || challengeId == "" || challengeSolution == "" || captchaId == "" || captchaVal == "" || payload == "" {
16-
return http.StatusBadRequest, "", errors.New("failed to login: missing required fields in request")
28+
return fail(http.StatusBadRequest, errInvalidLoginRequest, "login rejected: missing required fields")
1729
}
1830

1931
// 1. Check if the nonce is valid and not reused
2032
nc, err := base64.StdEncoding.DecodeString(nonce)
2133
if err != nil {
22-
return http.StatusBadRequest, "", errors.New("failed to login: provided nonce is invalid")
34+
return fail(http.StatusBadRequest, errInvalidLoginRequest, "login rejected: invalid nonce encoding: %v", err)
2335
}
2436
ncStr := string(nc)
2537
if t, ok := h.nonceCache.Get(ncStr); ok && time.Since(t) <= time.Hour {
26-
return http.StatusForbidden, "", errors.New("failed to login: replay attack detected")
38+
return fail(http.StatusForbidden, errAuthenticationFailed, "login rejected: nonce replay detected")
2739
}
2840
h.nonceCache.Add(ncStr, time.Now())
2941

3042
// 2. Verify PoW challenge solution
3143
challenge, ok := h.authChallengePool.GetAndDel(challengeId)
3244
if !ok {
33-
return http.StatusUnauthorized, "", errors.New("failed to login: invalid pre-auth challenge ID")
45+
return fail(http.StatusUnauthorized, errAuthenticationFailed, "login rejected: unknown challenge id")
3446
}
3547
if !challenge.isChallengeAlive() {
36-
return http.StatusUnauthorized, "", errors.New("failed to login: pre-auth challenge has expired, please try again")
48+
return fail(http.StatusUnauthorized, errAuthenticationFailed, "login rejected: challenge expired")
3749
}
38-
if !challenge.verifyChallenge(challengeId, challengeSolution) {
39-
return http.StatusUnauthorized, "", errors.New("failed to login: invalid challenge solution")
50+
if !challenge.verifyChallenge(challengeSolution) {
51+
return fail(http.StatusUnauthorized, errAuthenticationFailed, "login rejected: invalid PoW solution")
4052
}
4153

4254
// 3. Verify captcha solution
4355
if !captcha.VerifyString(captchaId, captchaVal) {
44-
return http.StatusUnauthorized, "", fmt.Errorf("failed to login: provided captcha solution %s is invalid", captchaVal)
56+
return fail(http.StatusUnauthorized, errAuthenticationFailed, "login rejected: invalid captcha")
4557
}
4658

4759
// 4. Extract RSA key pair from session ID
48-
kp, ok := h.keyPairDataPool.GetAndDel(sessionId)
60+
// Key pairs are shared across preauth requests within TTL, so do not delete on use.
61+
kp, ok := h.keyPairDataPool.Get(sessionId)
4962
if !ok {
50-
return http.StatusUnauthorized, "", errors.New("failed to login: provided session ID is invalid")
63+
return fail(http.StatusUnauthorized, errAuthenticationFailed, "login rejected: unknown session id")
5164
}
5265
if !kp.isKeyPairAlive() {
53-
return http.StatusUnauthorized, "", errors.New("failed to login: provided session ID has expired")
66+
h.keyPairDataPool.Del(sessionId)
67+
return fail(http.StatusUnauthorized, errAuthenticationFailed, "login rejected: session expired")
5468
}
5569

5670
// 5. Attempt to decrypt RSA encrypted AES secret from payload
5771
aesSecretBytesB64, err := kp.rsaKeyPair.Decrypt([]byte(secret), true)
5872
if err != nil {
59-
return http.StatusUnauthorized, "", fmt.Errorf("failed to login: failed to decrypt secret: %w", err)
73+
return fail(http.StatusUnauthorized, errAuthenticationFailed, "login rejected: secret decrypt failed: %v", err)
6074
}
6175
aesSecretBytes, err := base64.StdEncoding.DecodeString(string(aesSecretBytesB64))
6276
if err != nil {
63-
return http.StatusUnauthorized, "", fmt.Errorf("failed to login: failed to decode secret: %w", err)
77+
return fail(http.StatusUnauthorized, errAuthenticationFailed, "login rejected: secret decode failed: %v", err)
6478
}
6579
encryptor := newAES256GCM(aesSecretBytes)
80+
if encryptor == nil {
81+
return fail(http.StatusUnauthorized, errAuthenticationFailed, "login rejected: failed to init AES-GCM")
82+
}
6683

6784
// 6. Additional check to ensure data integrity (defense in depth)
6885
if _, err := encryptor.decrypt(nc, []byte(sessionId)); err != nil {
69-
return http.StatusForbidden, "", fmt.Errorf("failed to login: malformed nonce received: %w", err)
86+
return fail(http.StatusForbidden, errAuthenticationFailed, "login rejected: malformed nonce: %v", err)
7087
}
7188

7289
// 7. Attempt to decrypt AES encrypted payload containing credential
7390
pl, err := base64.StdEncoding.DecodeString(payload)
7491
if err != nil {
75-
return http.StatusBadRequest, "", errors.New("failed to login: provided payload is invalid")
92+
return fail(http.StatusBadRequest, errInvalidLoginRequest, "login rejected: invalid payload encoding: %v", err)
7693
}
7794
credential, err := encryptor.decrypt(pl, []byte(sessionId))
7895
if err != nil {
79-
return http.StatusUnauthorized, "", fmt.Errorf("failed to login: failed to decrypt payload: %w", err)
96+
return fail(http.StatusUnauthorized, errAuthenticationFailed, "login rejected: payload decrypt failed: %v", err)
8097
}
8198

8299
// 8. Unmarshal credential and perform login logic
83100
var credentialMap map[string]any
84101
if err = json.Unmarshal(credential, &credentialMap); err != nil {
85-
return http.StatusUnauthorized, "", fmt.Errorf("failed to login: failed to unmarshal payload: %w", err)
102+
return fail(http.StatusUnauthorized, errAuthenticationFailed, "login rejected: credential unmarshal failed: %v", err)
86103
}
87104
username, usernameOk := credentialMap["username"].(string)
88105
password, passwordOk := credentialMap["password"].(string)
89106
if !usernameOk || !passwordOk {
90-
return http.StatusUnauthorized, "", errors.New("failed to login: provided credential format is invalid")
107+
return fail(http.StatusUnauthorized, errAuthenticationFailed, "login rejected: credential format invalid")
91108
}
92109

93110
userId, err = h.actionHandler.SysUserLogin(username, password, userAgent, userIp)
94111
if err != nil {
95-
return http.StatusUnauthorized, "", fmt.Errorf("user login failed: %w", err)
112+
return fail(http.StatusUnauthorized, errAuthenticationFailed, "login rejected: user auth failed for %s: %v", username, err)
96113
}
97114

98115
return http.StatusOK, userId, nil

internal/server/router/auth/preauth.go

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,59 @@ package auth
33
import (
44
"bytes"
55
"encoding/base64"
6+
"errors"
67
"fmt"
78
"net/http"
89
"time"
910

1011
"github.com/dchest/captcha"
1112
)
1213

13-
func (h *auth) preAuth(ttl time.Duration) (code int, msg string, res any, err error) {
14+
func (h *auth) cleanupExpiredAuthState() {
15+
for key, pair := range h.keyPairDataPool.Iterator() {
16+
if !pair.isKeyPairAlive() {
17+
h.keyPairDataPool.Del(key)
18+
}
19+
}
20+
for key, attempt := range h.authChallengePool.Iterator() {
21+
if !attempt.isChallengeAlive() {
22+
h.authChallengePool.Del(key)
23+
}
24+
}
25+
}
26+
27+
func (h *auth) getSharedKeyPair(ttl time.Duration) (*keyPair, error) {
28+
h.keyPairMu.Lock()
29+
defer h.keyPairMu.Unlock()
30+
31+
for key, pair := range h.keyPairDataPool.Iterator() {
32+
if pair.isKeyPairAlive() {
33+
return pair, nil
34+
}
35+
h.keyPairDataPool.Del(key)
36+
}
37+
1438
kp, err := newKeyPair(ttl)
39+
if err != nil {
40+
return nil, err
41+
}
42+
h.keyPairDataPool.Set(kp.getKeyPairId(), kp)
43+
return kp, nil
44+
}
45+
46+
func (h *auth) preAuth(ttl time.Duration) (code int, msg string, res any, err error) {
47+
h.cleanupExpiredAuthState()
48+
49+
if h.authChallengePool.Len() >= maxPendingChallenges {
50+
errText := "too many pending login challenges"
51+
return http.StatusServiceUnavailable, errText, nil, errors.New(errText)
52+
}
53+
54+
kp, err := h.getSharedKeyPair(sharedKeyPairTTL)
1555
if err != nil {
1656
errText := "failed to generate new RSA key pair"
1757
return http.StatusInternalServerError, errText, nil, fmt.Errorf("%s: %w", errText, err)
1858
}
19-
h.keyPairDataPool.Set(kp.getKeyPairId(), kp)
2059

2160
_, pemPubKey, err := kp.rsaKeyPair.GetPEM(true)
2261
if err != nil {

0 commit comments

Comments
 (0)