386 lines
9.6 KiB
Go
386 lines
9.6 KiB
Go
|
package main
|
||
|
|
||
|
import (
|
||
|
"database/sql"
|
||
|
"encoding/json"
|
||
|
"fmt"
|
||
|
"net/http"
|
||
|
"net/url"
|
||
|
"strings"
|
||
|
"time"
|
||
|
|
||
|
"golang.org/x/crypto/bcrypt"
|
||
|
|
||
|
"github.com/gin-gonic/gin"
|
||
|
"github.com/pquerna/otp"
|
||
|
"github.com/pquerna/otp/totp"
|
||
|
"zxq.co/ripple/rippleapi/common"
|
||
|
"zxq.co/x/rs"
|
||
|
)
|
||
|
|
||
|
var allowedPaths = [...]string{
|
||
|
"/logout",
|
||
|
"/2fa_gateway",
|
||
|
"/2fa_gateway/verify",
|
||
|
"/2fa_gateway/clear",
|
||
|
"/2fa_gateway/recover",
|
||
|
"/favicon.ico",
|
||
|
}
|
||
|
|
||
|
// middleware to deny all requests to non-allowed pages
|
||
|
func twoFALock(c *gin.Context) {
|
||
|
// check it's not a static file
|
||
|
if len(c.Request.URL.Path) >= 8 && c.Request.URL.Path[:8] == "/static/" {
|
||
|
c.Next()
|
||
|
return
|
||
|
}
|
||
|
|
||
|
ctx := getContext(c)
|
||
|
if ctx.User.ID == 0 {
|
||
|
c.Next()
|
||
|
return
|
||
|
}
|
||
|
|
||
|
sess := getSession(c)
|
||
|
if v, _ := sess.Get("2fa_must_validate").(bool); !v {
|
||
|
// * check 2fa is enabled.
|
||
|
// if it is,
|
||
|
// * check whether the current ip is found in the database.
|
||
|
// if it is, move on and show the page.
|
||
|
// if it isn't, set 2fa_must_validate
|
||
|
// if it isn't, move on.
|
||
|
if is2faEnabled(ctx.User.ID) > 0 {
|
||
|
err := db.QueryRow("SELECT 1 FROM ip_user WHERE userid = ? AND ip = ? LIMIT 1", ctx.User.ID, clientIP(c)).Scan(new(int))
|
||
|
if err != sql.ErrNoRows {
|
||
|
c.Next()
|
||
|
return
|
||
|
}
|
||
|
sess.Set("2fa_must_validate", true)
|
||
|
} else {
|
||
|
c.Next()
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// check it's one of the few approved paths
|
||
|
for _, a := range allowedPaths {
|
||
|
if a == c.Request.URL.Path {
|
||
|
sess.Save()
|
||
|
c.Next()
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
addMessage(c, warningMessage{T(c, "You need to complete the 2fa challenge first.")})
|
||
|
sess.Save()
|
||
|
query := c.Request.URL.RawQuery
|
||
|
if query != "" {
|
||
|
query = "?" + query
|
||
|
}
|
||
|
c.Redirect(302, "/2fa_gateway?redir="+url.QueryEscape(c.Request.URL.Path+query))
|
||
|
c.Abort()
|
||
|
}
|
||
|
|
||
|
const (
|
||
|
tfaEnabledTelegram = 1 << iota
|
||
|
tfaEnabledTOTP
|
||
|
)
|
||
|
|
||
|
// is2faEnabled checks 2fa is enabled for an user.
|
||
|
func is2faEnabled(user int) int {
|
||
|
var enabled int
|
||
|
db.QueryRow("SELECT IFNULL((SELECT 1 FROM 2fa_telegram WHERE userid = ?), 0) | IFNULL((SELECT 2 FROM 2fa_totp WHERE userid = ? AND enabled = 1), 0) as x", user, user).
|
||
|
Scan(&enabled)
|
||
|
return enabled
|
||
|
}
|
||
|
|
||
|
func tfaGateway(c *gin.Context) {
|
||
|
sess := getSession(c)
|
||
|
|
||
|
redir := c.Query("redir")
|
||
|
switch {
|
||
|
case redir == "":
|
||
|
redir = "/"
|
||
|
case redir[0] != '/':
|
||
|
redir = "/"
|
||
|
}
|
||
|
|
||
|
i, _ := sess.Get("userid").(int)
|
||
|
if i == 0 {
|
||
|
c.Redirect(302, redir)
|
||
|
}
|
||
|
|
||
|
// check 2fa hasn't been disabled
|
||
|
e := is2faEnabled(i)
|
||
|
if e == 0 {
|
||
|
sess.Delete("2fa_must_validate")
|
||
|
sess.Save()
|
||
|
c.Redirect(302, redir)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
if e == 1 {
|
||
|
// check previous 2fa thing is still valid
|
||
|
err := db.QueryRow("SELECT 1 FROM 2fa WHERE userid = ? AND ip = ? AND expire > ?",
|
||
|
i, clientIP(c), time.Now().Unix()).Scan(new(int))
|
||
|
if err != nil {
|
||
|
db.Exec("INSERT INTO 2fa(userid, token, ip, expire, sent) VALUES (?, ?, ?, ?, 0);",
|
||
|
i, strings.ToUpper(rs.String(8)), clientIP(c), time.Now().Add(time.Hour).Unix())
|
||
|
http.Get("http://127.0.0.1:8888/update")
|
||
|
}
|
||
|
}
|
||
|
|
||
|
resp(c, 200, "2fa_gateway.html", &baseTemplateData{
|
||
|
TitleBar: "Two Factor Authentication",
|
||
|
KyutGrill: "2fa.jpg",
|
||
|
RequestInfo: map[string]interface{}{
|
||
|
"redir": redir,
|
||
|
},
|
||
|
})
|
||
|
}
|
||
|
|
||
|
func clientIP(c *gin.Context) string {
|
||
|
ff := c.Request.Header.Get("CF-Connecting-IP")
|
||
|
if ff != "" {
|
||
|
return ff
|
||
|
}
|
||
|
return c.ClientIP()
|
||
|
}
|
||
|
|
||
|
func clear2fa(c *gin.Context) {
|
||
|
// basically deletes from db 2fa tokens, so that it gets regenerated when user hits gateway page
|
||
|
sess := getSession(c)
|
||
|
i, _ := sess.Get("userid").(int)
|
||
|
if i == 0 {
|
||
|
c.Redirect(302, "/")
|
||
|
}
|
||
|
db.Exec("DELETE FROM 2fa WHERE userid = ? AND ip = ?", i, clientIP(c))
|
||
|
addMessage(c, successMessage{T(c, "A new code has been generated and sent to you through Telegram.")})
|
||
|
sess.Save()
|
||
|
c.Redirect(302, "/2fa_gateway")
|
||
|
}
|
||
|
|
||
|
func verify2fa(c *gin.Context) {
|
||
|
sess := getSession(c)
|
||
|
i, _ := sess.Get("userid").(int)
|
||
|
if i == 0 {
|
||
|
c.Redirect(302, "/")
|
||
|
}
|
||
|
e := is2faEnabled(i)
|
||
|
switch e {
|
||
|
case 1:
|
||
|
var id int
|
||
|
var expire common.UnixTimestamp
|
||
|
err := db.QueryRow("SELECT id, expire FROM 2fa WHERE userid = ? AND ip = ? AND token = ?", i, clientIP(c), strings.ToUpper(c.Query("token"))).Scan(&id, &expire)
|
||
|
if err == sql.ErrNoRows {
|
||
|
c.String(200, "1")
|
||
|
return
|
||
|
}
|
||
|
if time.Now().After(time.Time(expire)) {
|
||
|
c.String(200, "1")
|
||
|
db.Exec("INSERT INTO 2fa(userid, token, ip, expire, sent) VALUES (?, ?, ?, ?, 0);",
|
||
|
i, strings.ToUpper(rs.String(8)), clientIP(c), time.Now().Add(time.Hour).Unix())
|
||
|
http.Get("http://127.0.0.1:8888/update")
|
||
|
return
|
||
|
}
|
||
|
case 2:
|
||
|
var secret string
|
||
|
db.Get(&secret, "SELECT secret FROM 2fa_totp WHERE userid = ?", i)
|
||
|
if !totp.Validate(strings.Replace(c.Query("token"), " ", "", -1), secret) {
|
||
|
c.String(200, "1")
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
|
||
|
loginUser(c, i)
|
||
|
|
||
|
db.Exec("DELETE FROM 2fa WHERE id = ?", i)
|
||
|
c.String(200, "0")
|
||
|
}
|
||
|
|
||
|
func loginUser(c *gin.Context, i int) {
|
||
|
var d struct {
|
||
|
Country string
|
||
|
Flags uint
|
||
|
}
|
||
|
err := db.Get(&d, "SELECT users_stats.country, users.flags FROM users_stats "+
|
||
|
"LEFT JOIN users ON users.id = users_stats.id WHERE users_stats.id = ?", i)
|
||
|
if err != nil {
|
||
|
c.Error(err)
|
||
|
}
|
||
|
|
||
|
afterLogin(c, i, d.Country, d.Flags)
|
||
|
|
||
|
addMessage(c, successMessage{T(c, "You've been successfully logged in.")})
|
||
|
|
||
|
sess := getSession(c)
|
||
|
sess.Delete("2fa_must_validate")
|
||
|
sess.Save()
|
||
|
}
|
||
|
|
||
|
func recover2fa(c *gin.Context) {
|
||
|
sess := getSession(c)
|
||
|
i, _ := sess.Get("userid").(int)
|
||
|
if i == 0 {
|
||
|
c.Redirect(302, "/")
|
||
|
}
|
||
|
e := is2faEnabled(i)
|
||
|
if e != 2 {
|
||
|
respEmpty(c, "Recover account", warningMessage{T(c, "Oh no you don't.")})
|
||
|
return
|
||
|
}
|
||
|
resp(c, 200, "2fa_gateway_recover.html", &baseTemplateData{
|
||
|
TitleBar: T(c, "Recover account"),
|
||
|
KyutGrill: "2fa.jpg",
|
||
|
})
|
||
|
}
|
||
|
|
||
|
func recover2faSubmit(c *gin.Context) {
|
||
|
sess := getSession(c)
|
||
|
i, _ := sess.Get("userid").(int)
|
||
|
if i == 0 {
|
||
|
c.Redirect(302, "/")
|
||
|
}
|
||
|
if is2faEnabled(i) != 2 {
|
||
|
respEmpty(c, T(c, "Recover account"), warningMessage{T(c, "Get out.")})
|
||
|
return
|
||
|
}
|
||
|
|
||
|
var codesRaw string
|
||
|
db.Get(&codesRaw, "SELECT recovery FROM 2fa_totp WHERE userid = ?", i)
|
||
|
var codes []string
|
||
|
json.Unmarshal([]byte(codesRaw), &codes)
|
||
|
|
||
|
for k, v := range codes {
|
||
|
if v == c.PostForm("recovery_code") {
|
||
|
codes[k] = codes[len(codes)-1]
|
||
|
codes = codes[:len(codes)-1]
|
||
|
b, _ := json.Marshal(codes)
|
||
|
db.Exec("UPDATE 2fa_totp SET recovery = ? WHERE userid = ?", string(b), i)
|
||
|
|
||
|
loginUser(c, i)
|
||
|
c.Redirect(302, "/")
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
|
||
|
resp(c, 200, "2fa_gateway_recover.html", &baseTemplateData{
|
||
|
TitleBar: T(c, "Recover account"),
|
||
|
KyutGrill: "2fa.jpg",
|
||
|
Messages: []message{errorMessage{T(c, "Recovery code is invalid.")}},
|
||
|
})
|
||
|
}
|
||
|
|
||
|
// deletes expired 2fa confirmation tokens. gets current confirmation token.
|
||
|
// if it does not exist, generates one.
|
||
|
func get2faConfirmationToken(user int) (token string) {
|
||
|
db.Exec("DELETE FROM 2fa_confirmation WHERE expire < ?", time.Now().Unix())
|
||
|
db.Get(&token, "SELECT token FROM 2fa_confirmation WHERE userid = ? LIMIT 1", user)
|
||
|
if token != "" {
|
||
|
return
|
||
|
}
|
||
|
token = rs.String(32)
|
||
|
db.Exec("INSERT INTO 2fa_confirmation (userid, token, expire) VALUES (?, ?, ?)",
|
||
|
user, token, time.Now().Add(time.Hour).Unix())
|
||
|
return
|
||
|
}
|
||
|
|
||
|
func disable2fa(c *gin.Context) {
|
||
|
ctx := getContext(c)
|
||
|
if ctx.User.ID == 0 {
|
||
|
resp403(c)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
s := getSession(c)
|
||
|
var m message
|
||
|
defer func() {
|
||
|
addMessage(c, m)
|
||
|
s.Save()
|
||
|
c.Redirect(302, "/settings/2fa")
|
||
|
}()
|
||
|
|
||
|
if ok, _ := CSRF.Validate(ctx.User.ID, c.PostForm("csrf")); !ok {
|
||
|
m = errorMessage{T(c, "Your session has expired. Please try redoing what you were trying to do.")}
|
||
|
return
|
||
|
}
|
||
|
|
||
|
var pass string
|
||
|
db.Get(&pass, "SELECT password_md5 FROM users WHERE id = ?", ctx.User.ID)
|
||
|
if err := bcrypt.CompareHashAndPassword(
|
||
|
[]byte(pass),
|
||
|
[]byte(cmd5(c.PostForm("password"))),
|
||
|
); err != nil {
|
||
|
m = errorMessage{"Wrong password."}
|
||
|
return
|
||
|
}
|
||
|
|
||
|
db.Exec("DELETE FROM 2fa_telegram WHERE userid = ?", ctx.User.ID)
|
||
|
db.Exec("DELETE FROM 2fa_totp WHERE userid = ?", ctx.User.ID)
|
||
|
m = successMessage{T(c, "2FA disabled successfully.")}
|
||
|
}
|
||
|
|
||
|
func totpSetup(c *gin.Context) {
|
||
|
ctx := getContext(c)
|
||
|
sess := getSession(c)
|
||
|
if ctx.User.ID == 0 {
|
||
|
resp403(c)
|
||
|
return
|
||
|
}
|
||
|
defer c.Redirect(302, "/settings/2fa")
|
||
|
defer sess.Save()
|
||
|
|
||
|
if ok, _ := CSRF.Validate(ctx.User.ID, c.PostForm("csrf")); !ok {
|
||
|
addMessage(c, errorMessage{T(c, "Your session has expired. Please try redoing what you were trying to do.")})
|
||
|
return
|
||
|
}
|
||
|
|
||
|
switch is2faEnabled(ctx.User.ID) {
|
||
|
case 1:
|
||
|
addMessage(c, errorMessage{T(c, "You currently have Telegram 2FA enabled. You first need to disable that if you want to use TOTP-based 2FA.")})
|
||
|
return
|
||
|
case 2:
|
||
|
addMessage(c, errorMessage{T(c, "TOTP-based 2FA is already enabled!")})
|
||
|
return
|
||
|
}
|
||
|
|
||
|
pc := strings.Replace(c.PostForm("passcode"), " ", "", -1)
|
||
|
|
||
|
var secret string
|
||
|
db.Get(&secret, "SELECT secret FROM 2fa_totp WHERE userid = ?", ctx.User.ID)
|
||
|
if secret == "" || pc == "" {
|
||
|
addMessage(c, errorMessage{T(c, "No passcode/secret was given. Please try again")})
|
||
|
return
|
||
|
}
|
||
|
|
||
|
fmt.Println(pc, secret)
|
||
|
if !totp.Validate(pc, secret) {
|
||
|
addMessage(c, errorMessage{T(c, "Passcode is invalid. Perhaps it expired?")})
|
||
|
return
|
||
|
}
|
||
|
|
||
|
codes, _ := json.Marshal(generateRecoveryCodes())
|
||
|
db.Exec("UPDATE 2fa_totp SET recovery = ?, enabled = 1 WHERE userid = ?", string(codes), ctx.User.ID)
|
||
|
|
||
|
addMessage(c, successMessage{T(c, "TOTP-based 2FA has been enabled on your account.")})
|
||
|
}
|
||
|
|
||
|
func generateRecoveryCodes() []string {
|
||
|
x := make([]string, 8)
|
||
|
for i := range x {
|
||
|
x[i] = rs.StringFromChars(6, "QWERTYUIOPASDFGHJKLZXCVBNM1234567890")
|
||
|
}
|
||
|
return x
|
||
|
}
|
||
|
|
||
|
func generateKey(ctx context) *otp.Key {
|
||
|
k, err := totp.Generate(totp.GenerateOpts{
|
||
|
Issuer: "Ripple",
|
||
|
AccountName: ctx.User.Username,
|
||
|
})
|
||
|
if err != nil {
|
||
|
return nil
|
||
|
}
|
||
|
db.Exec("INSERT INTO 2fa_totp(userid, secret) VALUES (?, ?) ON DUPLICATE KEY UPDATE secret = VALUES(secret)", ctx.User.ID, k.Secret())
|
||
|
return k
|
||
|
}
|