+2
go.mod
+2
go.mod
···
24
24
github.com/labstack/echo/v4 v4.13.3
25
25
github.com/lestrrat-go/jwx/v2 v2.0.12
26
26
github.com/multiformats/go-multihash v0.2.3
27
+
github.com/pquerna/otp v1.5.0
27
28
github.com/samber/slog-echo v1.16.1
28
29
github.com/urfave/cli/v2 v2.27.6
29
30
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e
···
37
38
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
38
39
github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect
39
40
github.com/beorn7/perks v1.0.1 // indirect
41
+
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
40
42
github.com/carlmjohnson/versioninfo v0.22.5 // indirect
41
43
github.com/cespare/xxhash/v2 v2.3.0 // indirect
42
44
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
+4
go.sum
+4
go.sum
···
20
20
github.com/bluesky-social/indigo v0.0.0-20250414202759-826fcdeaa36b/go.mod h1:yjdhLA1LkK8VDS/WPUoYPo25/Hq/8rX38Ftr67EsqKY=
21
21
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
22
22
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
23
+
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
24
+
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
23
25
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3SkEwmHoWBmX1DNXhXZqlTpq6s4tyJGc=
24
26
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
25
27
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
···
289
291
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
290
292
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0=
291
293
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw=
294
+
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
295
+
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
292
296
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
293
297
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
294
298
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+9
models/models.go
+9
models/models.go
···
7
7
"github.com/bluesky-social/indigo/atproto/crypto"
8
8
)
9
9
10
+
type TwoFactorType string
11
+
12
+
var (
13
+
TwoFactorTypeNone = TwoFactorType("none")
14
+
TwoFactorTypeTotp = TwoFactorType("totp")
15
+
)
16
+
10
17
type Repo struct {
11
18
Did string `gorm:"primaryKey"`
12
19
CreatedAt time.Time
···
23
30
Rev string
24
31
Root []byte
25
32
Preferences []byte
33
+
TwoFactorType TwoFactorType `gorm:"default:none"`
34
+
TotpSecret *string
26
35
}
27
36
28
37
func (r *Repo) SignFor(ctx context.Context, did string, msg []byte) ([]byte, error) {
+100
server/handle_account_totp_enroll.go
+100
server/handle_account_totp_enroll.go
···
1
+
package server
2
+
3
+
import (
4
+
"bytes"
5
+
"encoding/base64"
6
+
"fmt"
7
+
"image/png"
8
+
9
+
"github.com/haileyok/cocoon/internal/helpers"
10
+
"github.com/haileyok/cocoon/models"
11
+
"github.com/labstack/echo/v4"
12
+
"github.com/pquerna/otp/totp"
13
+
)
14
+
15
+
func (s *Server) handleAccountTotpEnrollGet(e echo.Context) error {
16
+
urepo, sess, err := s.getSessionRepoOrErr(e)
17
+
if err != nil {
18
+
return e.Redirect(303, "/account/signin")
19
+
}
20
+
21
+
if urepo.TwoFactorType == models.TwoFactorTypeTotp {
22
+
sess.AddFlash("You have already enabled TOTP", "error")
23
+
sess.Save(e.Request(), e.Response())
24
+
return e.Redirect(303, "/account")
25
+
} else if urepo.TwoFactorType != models.TwoFactorTypeNone {
26
+
sess.AddFlash("You have already have another 2FA method enabled", "error")
27
+
sess.Save(e.Request(), e.Response())
28
+
return e.Redirect(303, "/account")
29
+
}
30
+
31
+
secret, err := totp.Generate(totp.GenerateOpts{
32
+
Issuer: s.config.Hostname,
33
+
AccountName: urepo.Repo.Did,
34
+
})
35
+
if err != nil {
36
+
s.logger.Error("error generating totp secret", "error", err)
37
+
return helpers.ServerError(e, nil)
38
+
}
39
+
40
+
sess.Values["totp-secret"] = secret
41
+
if err := sess.Save(e.Request(), e.Response()); err != nil {
42
+
s.logger.Error("error saving session", "error", err)
43
+
44
+
return helpers.ServerError(e, nil)
45
+
}
46
+
47
+
var buf bytes.Buffer
48
+
img, err := secret.Image(200, 200)
49
+
if err != nil {
50
+
s.logger.Error("error generating image from secret", "error", err)
51
+
return helpers.ServerError(e, nil)
52
+
}
53
+
png.Encode(&buf, img)
54
+
55
+
b64img := fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(buf.Bytes()))
56
+
57
+
return e.Render(200, "totp_enroll.html", map[string]any{
58
+
"flashes": getFlashesFromSession(e, sess),
59
+
"Image": b64img,
60
+
})
61
+
}
62
+
63
+
type TotpEnrollRequest struct {
64
+
Code string `form:"code"`
65
+
}
66
+
67
+
func (s *Server) handleAccountTotpEnrollPost(e echo.Context) error {
68
+
urepo, sess, err := s.getSessionRepoOrErr(e)
69
+
if err != nil {
70
+
return e.Redirect(303, "/account/signin")
71
+
}
72
+
73
+
var req TotpEnrollRequest
74
+
if err := e.Bind(&req); err != nil {
75
+
s.logger.Error("error binding request for enroll totp", "error", err)
76
+
return helpers.ServerError(e, nil)
77
+
}
78
+
79
+
secret, ok := sess.Values["totp-secret"].(string)
80
+
if !ok {
81
+
return helpers.InputError(e, nil)
82
+
}
83
+
84
+
if !totp.Validate(req.Code, secret) {
85
+
sess.AddFlash("The provided code was not valid.", "error")
86
+
sess.Save(e.Request(), e.Response())
87
+
return e.Redirect(303, "/account/totp-enroll")
88
+
}
89
+
90
+
if err := s.db.Exec("UPDATE repos SET two_factor_type = ?, totp_secret = ? WHERE did = ?", nil, models.TwoFactorTypeTotp, secret, urepo.Repo.Did).Error; err != nil {
91
+
s.logger.Error("error updating database with totp token", "error", err)
92
+
return helpers.ServerError(e, nil)
93
+
}
94
+
95
+
sess.AddFlash("You have successfully enrolled in TOTP!", "success")
96
+
delete(sess.Values, "totp-secret")
97
+
sess.Save(e.Request(), e.Response())
98
+
99
+
return e.Redirect(303, "/account")
100
+
}
+2
server/server.go
+2
server/server.go
···
690
690
s.echo.GET("/account/signin", s.handleAccountSigninGet)
691
691
s.echo.POST("/account/signin", s.handleAccountSigninPost)
692
692
s.echo.GET("/account/signout", s.handleAccountSignout)
693
+
s.echo.GET("/account/totp-enroll", s.handleAccountTotpEnrollGet)
694
+
s.echo.POST("/account/totp-enroll", s.handleAccountTotpEnrollPost)
693
695
694
696
// oauth account
695
697
s.echo.GET("/oauth/jwks", s.handleOauthJwks)
+5
server/static/style.css
+5
server/static/style.css
+30
server/templates/totp_enroll.html
+30
server/templates/totp_enroll.html
···
1
+
<!doctype html>
2
+
<html lang="en">
3
+
<head>
4
+
<meta charset="utf-8" />
5
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6
+
<meta name="color-scheme" content="light dark" />
7
+
<link rel="stylesheet" href="/static/pico.css" />
8
+
<link rel="stylesheet" href="/static/style.css" />
9
+
<title>TOTP Enrollment</title>
10
+
</head>
11
+
<body class="centered-body">
12
+
<main class="container base-container box-shadow-container login-container">
13
+
<h2>TOTP Enrollment</h2>
14
+
<p>
15
+
Enroll in TOTP by adding the below secret to your TOTP manager and
16
+
verifying the code.
17
+
</p>
18
+
{{ if .flashes.errors }}
19
+
<div class="alert alert-danger margin-bottom-xs">
20
+
<p>{{ index .flashes.errors 0 }}</p>
21
+
</div>
22
+
<img src="{{ .Image }}" class="totp-image" />
23
+
{{ end }}
24
+
<form action="/account/totp-enroll" method="post">
25
+
<input name="code" id="code" placeholder="Code" />
26
+
<button class="primary" type="submit" value="Login">Enroll</button>
27
+
</form>
28
+
</main>
29
+
</body>
30
+
</html>