package controller import ( "arimelody-web/model" "crypto/hmac" "crypto/rand" "crypto/sha1" "encoding/base32" "encoding/binary" "fmt" "math" "net/url" "os" "strings" "time" "github.com/jmoiron/sqlx" ) const TOTP_SECRET_LENGTH = 32 const TOTP_TIME_STEP int64 = 30 const TOTP_CODE_LENGTH = 6 func GenerateTOTP(secret string, timeStepOffset int) string { decodedSecret, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret) if err != nil { fmt.Fprintf(os.Stderr, "WARN: Invalid Base32 secret\n") } counter := time.Now().Unix() / TOTP_TIME_STEP - int64(timeStepOffset) counterBytes := make([]byte, 8) binary.BigEndian.PutUint64(counterBytes, uint64(counter)) mac := hmac.New(sha1.New, []byte(decodedSecret)) mac.Write(counterBytes) hash := mac.Sum(nil) offset := hash[len(hash) - 1] & 0x0f binaryCode := int32(binary.BigEndian.Uint32(hash[offset : offset + 4]) & 0x7FFFFFFF) code := binaryCode % int32(math.Pow10(TOTP_CODE_LENGTH)) return fmt.Sprintf(fmt.Sprintf("%%0%dd", TOTP_CODE_LENGTH), code) } func GenerateTOTPSecret(length int) string { bytes := make([]byte, length) _, err := rand.Read(bytes) if err != nil { panic("FATAL: Failed to generate random TOTP bytes") } secret := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(bytes) return strings.ToUpper(secret) } func GenerateTOTPURI(username string, secret string) string { url := url.URL{ Scheme: "otpauth", Host: "totp", Path: url.QueryEscape("arimelody.me") + ":" + url.QueryEscape(username), } query := url.Query() query.Set("secret", secret) query.Set("issuer", "arimelody.me") // query.Set("algorithm", "SHA1") // query.Set("digits", fmt.Sprintf("%d", TOTP_CODE_LENGTH)) // query.Set("period", fmt.Sprintf("%d", TOTP_TIME_STEP)) url.RawQuery = query.Encode() return url.String() } func GetTOTPsForAccount(db *sqlx.DB, accountID string) ([]model.TOTP, error) { totps := []model.TOTP{} err := db.Select( &totps, "SELECT * FROM totp " + "WHERE account=$1 AND confirmed=true " + "ORDER BY created_at ASC", accountID, ) if err != nil { return nil, err } return totps, nil } func CheckTOTPForAccount(db *sqlx.DB, accountID string, totp string) (*model.TOTP, error) { totps, err := GetTOTPsForAccount(db, accountID) if err != nil { return nil, err } for _, method := range totps { check := GenerateTOTP(method.Secret, 0) if check == totp { return &method, nil } // try again with offset- maybe user input the code late? check = GenerateTOTP(method.Secret, 1) if check == totp { return &method, nil } } // user failed all TOTP checks // note: this state will still occur even if the account has no TOTP methods. return nil, nil } func GetTOTP(db *sqlx.DB, accountID string, name string) (*model.TOTP, error) { totp := model.TOTP{} err := db.Get( &totp, "SELECT * FROM totp " + "WHERE account=$1 AND name=$2", accountID, name, ) if err != nil { if strings.Contains(err.Error(), "no rows") { return nil, nil } return nil, err } return &totp, nil } func ConfirmTOTP(db *sqlx.DB, accountID string, name string) error { _, err := db.Exec( "UPDATE totp SET confirmed=true WHERE account=$1 AND name=$2", accountID, name, ) return err } func CreateTOTP(db *sqlx.DB, totp *model.TOTP) error { _, err := db.Exec( "INSERT INTO totp (account, name, secret) " + "VALUES ($1,$2,$3)", totp.AccountID, totp.Name, totp.Secret, ) return err } func DeleteTOTP(db *sqlx.DB, accountID string, name string) error { _, err := db.Exec( "DELETE FROM totp WHERE account=$1 AND name=$2", accountID, name, ) return err } func DeleteUnconfirmedTOTPs(db *sqlx.DB) error { _, err := db.Exec("DELETE FROM totp WHERE confirmed=false") return err }