Swap out TOTP library

This commit is contained in:
Melon 2023-10-16 16:47:18 +01:00
parent 0b63e87691
commit 6dca637a16
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
6 changed files with 86 additions and 114 deletions

View File

@ -35,6 +35,7 @@ CREATE TABLE IF NOT EXISTS client_store
CREATE TABLE IF NOT EXISTS otp CREATE TABLE IF NOT EXISTS otp
( (
subject TEXT PRIMARY KEY UNIQUE NOT NULL, subject TEXT PRIMARY KEY UNIQUE NOT NULL,
raw BLOB NOT NULL, secret TEXT NOT NULL,
digits INTEGER NOT NULL,
FOREIGN KEY (subject) REFERENCES users (subject) FOREIGN KEY (subject) REFERENCES users (subject)
); );

View File

@ -4,7 +4,6 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"github.com/1f349/tulip/password" "github.com/1f349/tulip/password"
"github.com/1f349/twofactor"
"github.com/go-oauth2/oauth2/v4" "github.com/go-oauth2/oauth2/v4"
"github.com/google/uuid" "github.com/google/uuid"
"time" "time"
@ -167,23 +166,20 @@ WHERE subject = ?`,
return nil return nil
} }
func (t *Tx) SetTwoFactor(sub uuid.UUID, totp *twofactor.Totp) error { func (t *Tx) SetTwoFactor(sub uuid.UUID, secret string, digits int) error {
u, err := totp.ToBytes() _, err := t.tx.Exec(`INSERT INTO otp(subject, secret, digits) VALUES (?, ?, ?) ON CONFLICT(subject) DO UPDATE SET secret = excluded.secret, digits = excluded.digits`, sub.String(), secret, digits)
if err != nil {
return err
}
_, err = t.tx.Exec(`INSERT INTO otp(subject, raw) VALUES (?, ?) ON CONFLICT(subject) DO UPDATE SET raw = excluded.raw`, sub.String(), u)
return err return err
} }
func (t *Tx) GetTwoFactor(sub uuid.UUID, issuer string) (*twofactor.Totp, error) { func (t *Tx) GetTwoFactor(sub uuid.UUID) (string, int, error) {
var u []byte var secret string
row := t.tx.QueryRow(`SELECT raw FROM otp WHERE subject = ?`, sub.String()) var digits int
err := row.Scan(&u) row := t.tx.QueryRow(`SELECT secret, digits FROM otp WHERE subject = ?`, sub.String())
err := row.Scan(&secret, &digits)
if err != nil { if err != nil {
return nil, err return "", 0, err
} }
return twofactor.TOTPFromBytes(u, issuer) return secret, digits, nil
} }
func (t *Tx) HasTwoFactor(sub uuid.UUID) (bool, error) { func (t *Tx) HasTwoFactor(sub uuid.UUID) (bool, error) {

6
go.mod
View File

@ -5,7 +5,6 @@ go 1.21.1
require ( require (
github.com/1f349/cache v0.0.2 github.com/1f349/cache v0.0.2
github.com/1f349/overlapfs v0.0.1 github.com/1f349/overlapfs v0.0.1
github.com/1f349/twofactor v1.0.4
github.com/1f349/violet v0.0.9 github.com/1f349/violet v0.0.9
github.com/MrMelon54/exit-reload v0.0.1 github.com/MrMelon54/exit-reload v0.0.1
github.com/MrMelon54/pronouns v1.0.1 github.com/MrMelon54/pronouns v1.0.1
@ -18,7 +17,9 @@ require (
github.com/google/uuid v1.3.1 github.com/google/uuid v1.3.1
github.com/julienschmidt/httprouter v1.3.0 github.com/julienschmidt/httprouter v1.3.0
github.com/mattn/go-sqlite3 v1.14.17 github.com/mattn/go-sqlite3 v1.14.17
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/stretchr/testify v1.8.4 github.com/stretchr/testify v1.8.4
github.com/xlzd/gotp v0.1.0
golang.org/x/crypto v0.13.0 golang.org/x/crypto v0.13.0
golang.org/x/text v0.13.0 golang.org/x/text v0.13.0
) )
@ -29,9 +30,6 @@ require (
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sec51/convert v1.0.2 // indirect
github.com/sec51/gf256 v0.0.0-20160126143050-2454accbeb9e // indirect
github.com/sec51/qrcode v0.0.0-20160126144534-b7779abbcaf1 // indirect
github.com/tidwall/btree v1.6.0 // indirect github.com/tidwall/btree v1.6.0 // indirect
github.com/tidwall/buntdb v1.3.0 // indirect github.com/tidwall/buntdb v1.3.0 // indirect
github.com/tidwall/gjson v1.16.0 // indirect github.com/tidwall/gjson v1.16.0 // indirect

12
go.sum
View File

@ -3,8 +3,6 @@ github.com/1f349/cache v0.0.2 h1:27QD6zPd9xYyvh9V1qqWq+EAt5+N+qvyGWKfnjMrhP8=
github.com/1f349/cache v0.0.2/go.mod h1:LibAMy13dF0KO1fQA9aEjZPBCB6Y4b5kKYEQJUqc2rQ= github.com/1f349/cache v0.0.2/go.mod h1:LibAMy13dF0KO1fQA9aEjZPBCB6Y4b5kKYEQJUqc2rQ=
github.com/1f349/overlapfs v0.0.1 h1:LAxBolrXFAgU0yqZtXg/C/aaPq3eoQSPpBc49BHuTp0= github.com/1f349/overlapfs v0.0.1 h1:LAxBolrXFAgU0yqZtXg/C/aaPq3eoQSPpBc49BHuTp0=
github.com/1f349/overlapfs v0.0.1/go.mod h1:I6aItQycr7nrzplmfNXp/QF9tTmKRSgY3fXmu/7Ky2o= github.com/1f349/overlapfs v0.0.1/go.mod h1:I6aItQycr7nrzplmfNXp/QF9tTmKRSgY3fXmu/7Ky2o=
github.com/1f349/twofactor v1.0.4 h1:kN4EEGFlKRa7fGrxS+FpgwJI+tllES6YzXqCqurk4Uk=
github.com/1f349/twofactor v1.0.4/go.mod h1:gnG80vElwqLWNMnLT57yu4o4L1GdXGPP6pcIPlapXZs=
github.com/1f349/violet v0.0.9 h1:eQfc5fDMKJXVFUjS2UiAGTkOVVBamppD5dguhmU4GeU= github.com/1f349/violet v0.0.9 h1:eQfc5fDMKJXVFUjS2UiAGTkOVVBamppD5dguhmU4GeU=
github.com/1f349/violet v0.0.9/go.mod h1:Uzu6I1pLBP5UEzcUCTQBbk/NTfI5TAABSrowa8DSpR0= github.com/1f349/violet v0.0.9/go.mod h1:Uzu6I1pLBP5UEzcUCTQBbk/NTfI5TAABSrowa8DSpR0=
github.com/MrMelon54/exit-reload v0.0.1 h1:sxHa59tNEQMcikwuX2+93lw6Vi1+R7oCRF8a0C3alXc= github.com/MrMelon54/exit-reload v0.0.1 h1:sxHa59tNEQMcikwuX2+93lw6Vi1+R7oCRF8a0C3alXc=
@ -96,14 +94,10 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
github.com/sec51/convert v1.0.2 h1:NoKWIRARjM3rQglNypMpcXSLLqPsN/uTTzaGeqDKbeg=
github.com/sec51/convert v1.0.2/go.mod h1:5qL/cT/oiOIvWXy2SccQ7LnacYftqqy9wdyFkTc1k2w=
github.com/sec51/gf256 v0.0.0-20160126143050-2454accbeb9e h1:wKXba8dfsFjbxkMpzZBKt8gkJAMSm1fIf1OSWQFQrVA=
github.com/sec51/gf256 v0.0.0-20160126143050-2454accbeb9e/go.mod h1:hCjOqSOB9PBw5MdJ+0uSLCBV7FbLy0xwOR+c193HkcE=
github.com/sec51/qrcode v0.0.0-20160126144534-b7779abbcaf1 h1:CI9zS8HvMiibvXM/F3IthY797GW77fNYgioJl/8Xzzk=
github.com/sec51/qrcode v0.0.0-20160126144534-b7779abbcaf1/go.mod h1:uPm44Rj3uXSSOvmKmoeRuAUNUgwH2JHW5KIzqFFS/j4=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
@ -155,6 +149,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY= github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=

View File

@ -9,6 +9,8 @@
</header> </header>
<main> <main>
<form method="POST" action="/edit/otp"> <form method="POST" action="/edit/otp">
<input type="hidden" name="secret" value="{{.OtpSecret}}"/>
<input type="hidden" name="digits" value="{{.OtpDigits}}"/>
<p> <p>
<img src="{{.OtpQr}}" style="width:{{.QrWidth}}px" alt="OTP QR code not loading"/> <img src="{{.OtpQr}}" style="width:{{.QrWidth}}px" alt="OTP QR code not loading"/>
</p> </p>

View File

@ -2,17 +2,17 @@ package server
import ( import (
"bytes" "bytes"
"crypto"
"encoding/base64" "encoding/base64"
"github.com/1f349/tulip/database" "github.com/1f349/tulip/database"
"github.com/1f349/tulip/pages" "github.com/1f349/tulip/pages"
"github.com/1f349/twofactor"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"github.com/skip2/go-qrcode"
"github.com/xlzd/gotp"
"html/template" "html/template"
"image/png" "image/png"
"log"
"net/http" "net/http"
"time"
) )
func (h *HttpServer) LoginOtpGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { func (h *HttpServer) LoginOtpGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
@ -49,14 +49,15 @@ func (h *HttpServer) LoginOtpPost(rw http.ResponseWriter, req *http.Request, _ h
func (h *HttpServer) fetchAndValidateOtp(rw http.ResponseWriter, sub uuid.UUID, code string) bool { func (h *HttpServer) fetchAndValidateOtp(rw http.ResponseWriter, sub uuid.UUID, code string) bool {
var hasOtp bool var hasOtp bool
var otp *twofactor.Totp var secret string
var digits int
if h.DbTx(rw, func(tx *database.Tx) (err error) { if h.DbTx(rw, func(tx *database.Tx) (err error) {
hasOtp, err = tx.HasTwoFactor(sub) hasOtp, err = tx.HasTwoFactor(sub)
if err != nil { if err != nil {
return return
} }
if hasOtp { if hasOtp {
otp, err = tx.GetTwoFactor(sub, h.conf.OtpIssuer) secret, digits, err = tx.GetTwoFactor(sub)
} }
return return
}) { }) {
@ -64,14 +65,8 @@ func (h *HttpServer) fetchAndValidateOtp(rw http.ResponseWriter, sub uuid.UUID,
} }
if hasOtp { if hasOtp {
defer func() { totp := gotp.NewTOTP(secret, digits, 30, nil)
h.DbTx(rw, func(tx *database.Tx) error { if !verifyTotp(totp, code) {
return tx.SetTwoFactor(sub, otp)
})
}()
err := otp.Validate(code)
if err != nil {
http.Error(rw, "400 Bad Request: Invalid OTP code", http.StatusBadRequest) http.Error(rw, "400 Bad Request: Invalid OTP code", http.StatusBadRequest)
return true return true
} }
@ -81,7 +76,7 @@ func (h *HttpServer) fetchAndValidateOtp(rw http.ResponseWriter, sub uuid.UUID,
} }
func (h *HttpServer) EditOtpGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { func (h *HttpServer) EditOtpGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
var digits = 0 var digits int
switch req.URL.Query().Get("digits") { switch req.URL.Query().Get("digits") {
case "6": case "6":
digits = 6 digits = 6
@ -94,102 +89,86 @@ func (h *HttpServer) EditOtpGet(rw http.ResponseWriter, req *http.Request, _ htt
return return
} }
var otp *twofactor.Totp // get user email
var email string
otpRaw, ok := auth.Session.Get("temp-otp") if h.DbTx(rw, func(tx *database.Tx) error {
if ok {
if otp, ok = otpRaw.(*twofactor.Totp); !ok {
http.Error(rw, "400 Bad Request: invalid session, try clearing your cookies", http.StatusBadRequest)
return
}
// check OTP code matches number of digits
tempCode, err := otp.OTP()
if err != nil || len(tempCode) != digits {
otp = nil
}
}
// make a new otp handler if needed
if otp == nil {
// get user email
var email string
if h.DbTx(rw, func(tx *database.Tx) error {
var err error
email, err = tx.GetUserEmail(auth.Data.ID)
return err
}) {
return
}
// generate OTP key
var err error var err error
otp, err = twofactor.NewTOTP(email, h.conf.OtpIssuer, crypto.SHA512, digits) email, err = tx.GetUserEmail(auth.Data.ID)
if err != nil { return err
http.Error(rw, "500 Internal Server Error: Failed to generate OTP key", http.StatusInternalServerError) }) {
return
}
// save otp key
auth.Session.Set("temp-otp", otp)
err = auth.Session.Save()
if err != nil {
http.Error(rw, "500 Internal Server Error: Failed to save session", http.StatusInternalServerError)
return
}
}
// get qr and url
otpQr, err := otp.QR()
if err != nil {
http.Error(rw, "500 Internal Server Error: Failed to generate OTP QR code", http.StatusInternalServerError)
return return
} }
decode, err := png.Decode(bytes.NewReader(otpQr))
if err != nil { secret := gotp.RandomSecret(64)
if secret == "" {
http.Error(rw, "500 Internal Server Error: failed to generate OTP secret", http.StatusInternalServerError)
return return
} }
b := decode.Bounds() totp := gotp.NewTOTP(secret, digits, 30, nil)
qrWidth := b.Dx() / 4 otpUri := totp.ProvisioningUri(email, h.conf.OtpIssuer)
otpUrl, err := otp.URL() code, err := qrcode.New(otpUri, qrcode.Medium)
if err != nil { if err != nil {
http.Error(rw, "500 Internal Server Error: Failed to generate OTP URL", http.StatusInternalServerError) http.Error(rw, "500 Internal Server Error: failed to generate QR code", http.StatusInternalServerError)
return
}
qrImg := code.Image(60 * 4)
qrBounds := qrImg.Bounds()
qrWidth := qrBounds.Dx()
qrBuf := new(bytes.Buffer)
if png.Encode(qrBuf, qrImg) != nil {
http.Error(rw, "500 Internal Server Error: failed to generate PNG image of QR code", http.StatusInternalServerError)
return return
} }
// render page // render page
pages.RenderPageTemplate(rw, "edit-otp", map[string]any{ pages.RenderPageTemplate(rw, "edit-otp", map[string]any{
"ServiceName": h.conf.ServiceName, "ServiceName": h.conf.ServiceName,
"OtpQr": template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(otpQr)), "OtpQr": template.URL("data:qrImg/png;base64," + base64.StdEncoding.EncodeToString(qrBuf.Bytes())),
"QrWidth": qrWidth, "QrWidth": qrWidth,
"OtpUrl": otpUrl, "OtpUrl": otpUri,
"OtpSecret": secret,
"OtpDigits": digits,
}) })
} }
func (h *HttpServer) EditOtpPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) { func (h *HttpServer) EditOtpPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
var otp *twofactor.Totp var digits int
switch req.FormValue("digits") {
otpRaw, ok := auth.Session.Get("temp-otp") case "6":
if !ok { digits = 6
http.Error(rw, "400 Bad Request: invalid session, try clearing your cookies", http.StatusBadRequest) case "7":
return digits = 7
} case "8":
if otp, ok = otpRaw.(*twofactor.Totp); !ok { digits = 8
http.Error(rw, "400 Bad Request: invalid session, try clearing your cookies", http.StatusBadRequest) default:
return http.Error(rw, "400 Bad Request: Invalid number of digits for OTP code", http.StatusBadRequest)
}
err := otp.Validate(req.FormValue("code"))
if err != nil {
http.Error(rw, "400 Bad Request: invalid OTP code: "+err.Error(), http.StatusBadRequest)
log.Println()
return return
} }
if h.DbTx(rw, func(tx *database.Tx) error { secret := req.FormValue("secret")
return tx.SetTwoFactor(auth.Data.ID, otp) if !gotp.IsSecretValid(secret) {
}) { http.Error(rw, "400 Bad Request: Invalid secret", http.StatusBadRequest)
return
}
totp := gotp.NewTOTP(secret, digits, 30, nil)
if !verifyTotp(totp, req.FormValue("code")) {
http.Error(rw, "400 Bad Request: invalid OTP code", http.StatusBadRequest)
return return
} }
http.Redirect(rw, req, "/", http.StatusFound) http.Redirect(rw, req, "/", http.StatusFound)
} }
func verifyTotp(totp *gotp.TOTP, code string) bool {
t := time.Now()
if totp.VerifyTime(code, t) {
return true
}
if totp.VerifyTime(code, t.Add(-30*time.Second)) {
return true
}
return totp.VerifyTime(code, t.Add(30*time.Second))
}