An atproto PDS written in Go

totp

+2
go.mod
···
github.com/labstack/echo/v4 v4.13.3
github.com/lestrrat-go/jwx/v2 v2.0.12
github.com/multiformats/go-multihash v0.2.3
+
github.com/pquerna/otp v1.5.0
github.com/samber/slog-echo v1.16.1
github.com/urfave/cli/v2 v2.27.6
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e
···
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect
github.com/beorn7/perks v1.0.1 // indirect
+
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/carlmjohnson/versioninfo v0.22.5 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
+4
go.sum
···
github.com/bluesky-social/indigo v0.0.0-20250414202759-826fcdeaa36b/go.mod h1:yjdhLA1LkK8VDS/WPUoYPo25/Hq/8rX38Ftr67EsqKY=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
+
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
+
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3SkEwmHoWBmX1DNXhXZqlTpq6s4tyJGc=
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
···
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0=
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw=
+
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
+
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+9
models/models.go
···
"github.com/bluesky-social/indigo/atproto/crypto"
)
+
type TwoFactorType string
+
+
var (
+
TwoFactorTypeNone = TwoFactorType("none")
+
TwoFactorTypeTotp = TwoFactorType("totp")
+
)
+
type Repo struct {
Did string `gorm:"primaryKey"`
CreatedAt time.Time
···
Rev string
Root []byte
Preferences []byte
+
TwoFactorType TwoFactorType `gorm:"default:none"`
+
TotpSecret *string
}
func (r *Repo) SignFor(ctx context.Context, did string, msg []byte) ([]byte, error) {
+100
server/handle_account_totp_enroll.go
···
+
package server
+
+
import (
+
"bytes"
+
"encoding/base64"
+
"fmt"
+
"image/png"
+
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/models"
+
"github.com/labstack/echo/v4"
+
"github.com/pquerna/otp/totp"
+
)
+
+
func (s *Server) handleAccountTotpEnrollGet(e echo.Context) error {
+
urepo, sess, err := s.getSessionRepoOrErr(e)
+
if err != nil {
+
return e.Redirect(303, "/account/signin")
+
}
+
+
if urepo.TwoFactorType == models.TwoFactorTypeTotp {
+
sess.AddFlash("You have already enabled TOTP", "error")
+
sess.Save(e.Request(), e.Response())
+
return e.Redirect(303, "/account")
+
} else if urepo.TwoFactorType != models.TwoFactorTypeNone {
+
sess.AddFlash("You have already have another 2FA method enabled", "error")
+
sess.Save(e.Request(), e.Response())
+
return e.Redirect(303, "/account")
+
}
+
+
secret, err := totp.Generate(totp.GenerateOpts{
+
Issuer: s.config.Hostname,
+
AccountName: urepo.Repo.Did,
+
})
+
if err != nil {
+
s.logger.Error("error generating totp secret", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
sess.Values["totp-secret"] = secret
+
if err := sess.Save(e.Request(), e.Response()); err != nil {
+
s.logger.Error("error saving session", "error", err)
+
+
return helpers.ServerError(e, nil)
+
}
+
+
var buf bytes.Buffer
+
img, err := secret.Image(200, 200)
+
if err != nil {
+
s.logger.Error("error generating image from secret", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
png.Encode(&buf, img)
+
+
b64img := fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(buf.Bytes()))
+
+
return e.Render(200, "totp_enroll.html", map[string]any{
+
"flashes": getFlashesFromSession(e, sess),
+
"Image": b64img,
+
})
+
}
+
+
type TotpEnrollRequest struct {
+
Code string `form:"code"`
+
}
+
+
func (s *Server) handleAccountTotpEnrollPost(e echo.Context) error {
+
urepo, sess, err := s.getSessionRepoOrErr(e)
+
if err != nil {
+
return e.Redirect(303, "/account/signin")
+
}
+
+
var req TotpEnrollRequest
+
if err := e.Bind(&req); err != nil {
+
s.logger.Error("error binding request for enroll totp", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
secret, ok := sess.Values["totp-secret"].(string)
+
if !ok {
+
return helpers.InputError(e, nil)
+
}
+
+
if !totp.Validate(req.Code, secret) {
+
sess.AddFlash("The provided code was not valid.", "error")
+
sess.Save(e.Request(), e.Response())
+
return e.Redirect(303, "/account/totp-enroll")
+
}
+
+
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 {
+
s.logger.Error("error updating database with totp token", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
sess.AddFlash("You have successfully enrolled in TOTP!", "success")
+
delete(sess.Values, "totp-secret")
+
sess.Save(e.Request(), e.Response())
+
+
return e.Redirect(303, "/account")
+
}
+2
server/server.go
···
s.echo.GET("/account/signin", s.handleAccountSigninGet)
s.echo.POST("/account/signin", s.handleAccountSigninPost)
s.echo.GET("/account/signout", s.handleAccountSignout)
+
s.echo.GET("/account/totp-enroll", s.handleAccountTotpEnrollGet)
+
s.echo.POST("/account/totp-enroll", s.handleAccountTotpEnrollPost)
// oauth account
s.echo.GET("/oauth/jwks", s.handleOauthJwks)
+5
server/static/style.css
···
.alert-danger {
background-color: var(--danger);
}
+
+
.totp-image {
+
height: 200;
+
width: 200;
+
}
+30
server/templates/totp_enroll.html
···
+
<!doctype html>
+
<html lang="en">
+
<head>
+
<meta charset="utf-8" />
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
+
<meta name="color-scheme" content="light dark" />
+
<link rel="stylesheet" href="/static/pico.css" />
+
<link rel="stylesheet" href="/static/style.css" />
+
<title>TOTP Enrollment</title>
+
</head>
+
<body class="centered-body">
+
<main class="container base-container box-shadow-container login-container">
+
<h2>TOTP Enrollment</h2>
+
<p>
+
Enroll in TOTP by adding the below secret to your TOTP manager and
+
verifying the code.
+
</p>
+
{{ if .flashes.errors }}
+
<div class="alert alert-danger margin-bottom-xs">
+
<p>{{ index .flashes.errors 0 }}</p>
+
</div>
+
<img src="{{ .Image }}" class="totp-image" />
+
{{ end }}
+
<form action="/account/totp-enroll" method="post">
+
<input name="code" id="code" placeholder="Code" />
+
<button class="primary" type="submit" value="Login">Enroll</button>
+
</form>
+
</main>
+
</body>
+
</html>