diff --git a/cache/cache.go b/cache/cache.go index 81ab8e8..d859b71 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -15,6 +15,7 @@ type UserWithExpired struct { Username string Email string Password string + Totp string AccessAt time.Time mu sync.Mutex } @@ -103,6 +104,7 @@ func GetUser(email string) (*UserWithExpired, error) { Username: userData.Username, Email: userData.Email, Password: userData.Password, + Totp: userData.Totp, AccessAt: time.Now(), } diff --git a/db/database.go b/db/database.go index 319b0fb..668b017 100644 --- a/db/database.go +++ b/db/database.go @@ -45,6 +45,8 @@ type Database interface { UpdateUploadedByte(index int64, fileID string) UpdateUploadedChunk(index int64, fileID string) FinalizeFileUpload(fileID string) + + InitializeTotp(email string, secret string) error } func NewMYSQLdb(username, password, host, port, dbName string) Database { @@ -262,6 +264,20 @@ func (db *mySQLdb) FinalizeFileUpload(fileID string) { db.Save(&file) } +func (db *mySQLdb) InitializeTotp(email string, secret string) error { + var user models.User + err := db.DB.Table("users").Where("email = ?", email).First(&user).Error + if err != nil { + return err + } + user.Totp = secret + err = db.Save(&user).Error + if err != nil { + return err + } + return nil +} + // POSTGRES FUNCTION func (db *postgresDB) IsUserRegistered(email string, username string) bool { var data models.User @@ -369,3 +385,17 @@ func (db *postgresDB) FinalizeFileUpload(fileID string) { file.Done = true db.Save(&file) } + +func (db *postgresDB) InitializeTotp(email string, secret string) error { + var user models.User + err := db.DB.Table("users").Where("email = $1", email).First(&user).Error + if err != nil { + return err + } + user.Totp = secret + err = db.Save(&user).Error + if err != nil { + return err + } + return nil +} diff --git a/go.mod b/go.mod index 683d919..b967285 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,9 @@ require ( github.com/a-h/templ v0.2.648 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 + github.com/mdp/qrterminal/v3 v3.2.0 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + github.com/xlzd/gotp v0.1.0 golang.org/x/crypto v0.21.0 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gorm.io/driver/mysql v1.5.6 @@ -20,6 +23,9 @@ require ( github.com/jackc/pgx/v5 v5.4.3 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/term v0.18.0 // indirect golang.org/x/text v0.14.0 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect + rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index 3f53935..480b361 100644 --- a/go.sum +++ b/go.sum @@ -21,15 +21,25 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mdp/qrterminal/v3 v3.2.0 h1:qteQMXO3oyTK4IHwj2mWsKYYRBOp1Pj2WRYFYYNTCdk= +github.com/mdp/qrterminal/v3 v3.2.0/go.mod h1:XGGuua4Lefrl7TLEsSONiD+UEjQXJZ4mPzF+gWYIJkk= 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/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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po= +github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg= golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= @@ -47,3 +57,5 @@ gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4c gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.8 h1:WAGEZ/aEcznN4D03laj8DKnehe1e9gYQAjW8xyPRdeo= gorm.io/gorm v1.25.8/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/handler/auth/totp/totp.go b/handler/auth/totp/totp.go new file mode 100644 index 0000000..e095fb5 --- /dev/null +++ b/handler/auth/totp/totp.go @@ -0,0 +1,139 @@ +package totpHandler + +import ( + "errors" + "fmt" + "github.com/fossyy/filekeeper/session" + "github.com/fossyy/filekeeper/types" + "github.com/fossyy/filekeeper/utils" + totpView "github.com/fossyy/filekeeper/view/totp" + "github.com/xlzd/gotp" + "net/http" + "strings" + "time" +) + +func GET(w http.ResponseWriter, r *http.Request) { + _, user, _ := session.GetSession(r) + if user.Authenticated || user.Totp == "" { + w.WriteHeader(http.StatusNotFound) + return + } + component := totpView.Main("Filekeeper - 2FA Page") + err := component.Render(r.Context(), w) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + return +} + +func POST(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + code := r.Form.Get("code") + _, user, key := session.GetSession(r) + totp := gotp.NewDefaultTOTP(user.Totp) + + if totp.Verify(code, time.Now().Unix()) { + storeSession, err := session.Get(key) + if err != nil { + return + } + storeSession.Values["user"] = types.User{ + UserID: user.UserID, + Email: user.Email, + Username: user.Username, + Authenticated: true, + } + userAgent := r.Header.Get("User-Agent") + browserInfo, osInfo := ParseUserAgent(userAgent) + + sessionInfo := session.SessionInfo{ + SessionID: storeSession.ID, + Browser: browserInfo["browser"], + Version: browserInfo["version"], + OS: osInfo["os"], + OSVersion: osInfo["version"], + IP: utils.ClientIP(r), + Location: "Indonesia", + } + + storeSession.Save(w) + session.AddSessionInfo(user.Email, &sessionInfo) + + cookie, err := r.Cookie("redirect") + if errors.Is(err, http.ErrNoCookie) { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + http.SetCookie(w, &http.Cookie{ + Name: "redirect", + MaxAge: -1, + }) + http.Redirect(w, r, cookie.Value, http.StatusSeeOther) + return + } else { + fmt.Fprint(w, "wrong") + } +} + +func ParseUserAgent(userAgent string) (map[string]string, map[string]string) { + browserInfo := make(map[string]string) + osInfo := make(map[string]string) + if strings.Contains(userAgent, "Firefox") { + browserInfo["browser"] = "Firefox" + parts := strings.Split(userAgent, "Firefox/") + if len(parts) > 1 { + version := strings.Split(parts[1], " ")[0] + browserInfo["version"] = version + } + } else if strings.Contains(userAgent, "Chrome") { + browserInfo["browser"] = "Chrome" + parts := strings.Split(userAgent, "Chrome/") + if len(parts) > 1 { + version := strings.Split(parts[1], " ")[0] + browserInfo["version"] = version + } + } else { + browserInfo["browser"] = "Unknown" + browserInfo["version"] = "Unknown" + } + + if strings.Contains(userAgent, "Windows") { + osInfo["os"] = "Windows" + parts := strings.Split(userAgent, "Windows ") + if len(parts) > 1 { + version := strings.Split(parts[1], ";")[0] + osInfo["version"] = version + } + } else if strings.Contains(userAgent, "Macintosh") { + osInfo["os"] = "Mac OS" + parts := strings.Split(userAgent, "Mac OS X ") + if len(parts) > 1 { + version := strings.Split(parts[1], ";")[0] + osInfo["version"] = version + } + } else if strings.Contains(userAgent, "Linux") { + osInfo["os"] = "Linux" + osInfo["version"] = "Unknown" + } else if strings.Contains(userAgent, "Android") { + osInfo["os"] = "Android" + parts := strings.Split(userAgent, "Android ") + if len(parts) > 1 { + version := strings.Split(parts[1], ";")[0] + osInfo["version"] = version + } + } else if strings.Contains(userAgent, "iPhone") || strings.Contains(userAgent, "iPad") || strings.Contains(userAgent, "iPod") { + osInfo["os"] = "iOS" + parts := strings.Split(userAgent, "OS ") + if len(parts) > 1 { + version := strings.Split(parts[1], " ")[0] + osInfo["version"] = version + } + } else { + osInfo["os"] = "Unknown" + osInfo["version"] = "Unknown" + } + + return browserInfo, osInfo +} diff --git a/handler/signin/signin.go b/handler/signin/signin.go index d779153..edcde52 100644 --- a/handler/signin/signin.go +++ b/handler/signin/signin.go @@ -4,19 +4,22 @@ import ( "errors" "github.com/a-h/templ" "github.com/fossyy/filekeeper/cache" - "net/http" - "strings" - "github.com/fossyy/filekeeper/logger" "github.com/fossyy/filekeeper/session" "github.com/fossyy/filekeeper/types" "github.com/fossyy/filekeeper/utils" signinView "github.com/fossyy/filekeeper/view/signin" + "net/http" + "strings" ) var log *logger.AggregatedLogger var errorMessages = make(map[string]string) +func init() { + +} + func init() { errorMessages = map[string]string{ "redirect_uri_mismatch": "The redirect URI provided does not match the one registered with our service. Please contact the administrator for assistance.", @@ -92,6 +95,20 @@ func POST(w http.ResponseWriter, r *http.Request) { } if email == userData.Email && utils.CheckPasswordHash(password, userData.Password) { + if userData.Totp != "" { + storeSession := session.Create() + storeSession.Values["user"] = types.User{ + UserID: userData.UserID, + Email: email, + Username: userData.Username, + Totp: userData.Totp, + Authenticated: false, + } + storeSession.Save(w) + http.Redirect(w, r, "/auth/totp", http.StatusSeeOther) + return + } + storeSession := session.Create() storeSession.Values["user"] = types.User{ UserID: userData.UserID, diff --git a/handler/user/totp/setup.go b/handler/user/totp/setup.go new file mode 100644 index 0000000..065d8c6 --- /dev/null +++ b/handler/user/totp/setup.go @@ -0,0 +1,86 @@ +package userHandlerTotpSetup + +import ( + "bytes" + "encoding/base64" + "fmt" + "github.com/fossyy/filekeeper/cache" + userTotpSetupView "github.com/fossyy/filekeeper/view/user/totp" + "image/png" + "net/http" + "time" + + "github.com/fossyy/filekeeper/db" + "github.com/fossyy/filekeeper/types" + "github.com/skip2/go-qrcode" + "github.com/xlzd/gotp" +) + +func generateQRCode(uri string) (string, error) { + qr, err := qrcode.New(uri, qrcode.Medium) + if err != nil { + return "", fmt.Errorf("failed to generate QR code: %w", err) + } + + var buffer bytes.Buffer + if err := png.Encode(&buffer, qr.Image(256)); err != nil { + return "", fmt.Errorf("failed to encode QR code to PNG: %w", err) + } + + return base64.StdEncoding.EncodeToString(buffer.Bytes()), nil +} + +func GET(w http.ResponseWriter, r *http.Request) { + secret := gotp.RandomSecret(16) + userSession := r.Context().Value("user").(types.User) + totp := gotp.NewDefaultTOTP(secret) + uri := totp.ProvisioningUri(userSession.Email, "filekeeper") + base64Str, err := generateQRCode(uri) + if err != nil { + fmt.Printf("%v\n", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + component := userTotpSetupView.Main("Filekeeper - 2FA Setup Page", base64Str, secret, userSession) + if err := component.Render(r.Context(), w); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } +} + +func POST(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + code := r.Form.Get("totp") + secret := r.Form.Get("secret") + totp := gotp.NewDefaultTOTP(secret) + userSession := r.Context().Value("user").(types.User) + if totp.Verify(code, time.Now().Unix()) { + if err := db.DB.InitializeTotp(userSession.Email, secret); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + cache.DeleteUser(userSession.Email) + fmt.Fprint(w, "Authentication successful! Access granted.") + return + } else { + uri := totp.ProvisioningUri(userSession.Email, "filekeeper") + + base64Str, err := generateQRCode(uri) + if err != nil { + fmt.Printf("%v\n", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + component := userTotpSetupView.Main("Filekeeper - 2FA Setup Page", base64Str, secret, userSession) + if err := component.Render(r.Context(), w); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + } +} diff --git a/routes/routes.go b/routes/routes.go index 51c46df..8948521 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -4,6 +4,7 @@ import ( googleOauthHandler "github.com/fossyy/filekeeper/handler/auth/google" googleOauthCallbackHandler "github.com/fossyy/filekeeper/handler/auth/google/callback" googleOauthSetupHandler "github.com/fossyy/filekeeper/handler/auth/google/setup" + totpHandler "github.com/fossyy/filekeeper/handler/auth/totp" downloadHandler "github.com/fossyy/filekeeper/handler/download" downloadFileHandler "github.com/fossyy/filekeeper/handler/download/file" forgotPasswordHandler "github.com/fossyy/filekeeper/handler/forgotPassword" @@ -16,6 +17,7 @@ import ( uploadHandler "github.com/fossyy/filekeeper/handler/upload" "github.com/fossyy/filekeeper/handler/upload/initialisation" userHandler "github.com/fossyy/filekeeper/handler/user" + userHandlerTotpSetup "github.com/fossyy/filekeeper/handler/user/totp" "github.com/fossyy/filekeeper/middleware" "net/http" ) @@ -46,7 +48,17 @@ func SetupRoutes() *http.ServeMux { default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } + }) + authRouter.HandleFunc("/totp", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + middleware.Guest(totpHandler.GET, w, r) + case http.MethodPost: + middleware.Guest(totpHandler.POST, w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } }) authRouter.HandleFunc("/google/callback", func(w http.ResponseWriter, r *http.Request) { @@ -129,6 +141,17 @@ func SetupRoutes() *http.ServeMux { } }) + handler.HandleFunc("/user/totp/setup", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + middleware.Auth(userHandlerTotpSetup.GET, w, r) + case http.MethodPost: + middleware.Auth(userHandlerTotpSetup.POST, w, r) + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + }) + // Upload router uploadRouter := http.NewServeMux() handler.Handle("/upload/", http.StripPrefix("/upload", uploadRouter)) diff --git a/schema.sql b/schema.sql index 6a4b640..d705a30 100644 --- a/schema.sql +++ b/schema.sql @@ -1,2 +1,2 @@ -CREATE TABLE IF NOT EXISTS users (user_id VARCHAR(255) PRIMARY KEY NOT NULL,username VARCHAR(255) UNIQUE NOT NULL,email VARCHAR(255) UNIQUE NOT NULL,password TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS users (user_id VARCHAR(255) PRIMARY KEY NOT NULL,username VARCHAR(255) UNIQUE NOT NULL,email VARCHAR(255) UNIQUE NOT NULL,password TEXT NOT NULL,totp VARCHAR(255) DEFAULT NULL); CREATE TABLE IF NOT EXISTS files (id VARCHAR(255) PRIMARY KEY NOT NULL,owner_id VARCHAR(255) NOT NULL,name TEXT NOT NULL,size BIGINT NOT NULL,downloaded BIGINT NOT NULL,uploaded_byte BIGINT NOT NULL DEFAULT 0, uploaded_chunk BIGINT NOT NULL DEFAULT -1,done BOOLEAN NOT NULL DEFAULT FALSE,FOREIGN KEY (owner_id) REFERENCES users(user_id)); \ No newline at end of file diff --git a/session/session.go b/session/session.go index 0a1ed1e..6f18872 100644 --- a/session/session.go +++ b/session/session.go @@ -176,6 +176,14 @@ func GetSession(r *http.Request) (UserStatus, types.User, string) { return Unauthorized, types.User{}, "" } + if !userSession.Authenticated && userSession.Totp != "" { + return Unauthorized, userSession, cookie.Value + } + + if !userSession.Authenticated { + return Unauthorized, types.User{}, "" + } + return Authorized, userSession, cookie.Value } diff --git a/types/models/models.go b/types/models/models.go index 50743bf..804a953 100644 --- a/types/models/models.go +++ b/types/models/models.go @@ -7,6 +7,7 @@ type User struct { Username string `gorm:"unique;not null"` Email string `gorm:"unique;not null"` Password string `gorm:"not null"` + Totp string `gorm:"not null"` } type File struct { diff --git a/types/types.go b/types/types.go index 8c5c410..7eda14f 100644 --- a/types/types.go +++ b/types/types.go @@ -13,6 +13,7 @@ type User struct { UserID uuid.UUID Email string Username string + Totp string Authenticated bool } diff --git a/view/totp/totp.templ b/view/totp/totp.templ new file mode 100644 index 0000000..3694d1c --- /dev/null +++ b/view/totp/totp.templ @@ -0,0 +1,54 @@ +package totpView + +import ( + "github.com/fossyy/filekeeper/view/layout" +) + +templ content(title string) { + @layout.Base(title){ +
+
+
+
+

Verify Your Identity

+

+ Enter the 6-digit code sent to your registered device to complete the login process. +

+
+
+
+ +
+ +
+
+
+ +
+
+
+
+
+ } +} + +templ Main(title string) { + @content(title) +} \ No newline at end of file diff --git a/view/user/totp/setup.templ b/view/user/totp/setup.templ new file mode 100644 index 0000000..1aedcad --- /dev/null +++ b/view/user/totp/setup.templ @@ -0,0 +1,97 @@ +package userTotpSetupView + +import ( + "github.com/fossyy/filekeeper/view/layout" + "github.com/fossyy/filekeeper/types" +) + +templ content(title string, qrcode string, code string, user types.User) { + @layout.Base(title){ + @layout.Navbar(user) +
+
+
+
+ + + + + + +

Set up Two-Factor Authentication

+
+

Secure your account with time-based one-time passwords (TOTP).

+
+

Here's how to set up the Google Authenticator app:

+
    +
  1. Download the Google Authenticator app on your mobile device.
  2. +
  3. Open the app and tap "Begin Setup".
  4. +
  5. Select "Scan a barcode" and point your camera at the QR code below.
  6. +
  7. The app will automatically add your account and display a 6-digit code.
  8. +
  9. Enter this code on the website to complete the setup.
  10. +
+
+
+
+
+
+ QR Code +
+
+

Backup Code:

+
12345-67890
+

TOTP Secret:

+
+ {code} +
+
+
+
+ + + +
+ +
+
+
+
+
+
+
+ @layout.Footer() + } +} + +templ Main(title string, qrcode string, code string, user types.User) { + @content(title, qrcode, code, user) +} \ No newline at end of file diff --git a/view/user/user.templ b/view/user/user.templ index 0825dda..f6f2084 100644 --- a/view/user/user.templ +++ b/view/user/user.templ @@ -71,15 +71,13 @@ templ content(title string, user types.User, ListSession []*session.SessionInfo) for="two-factor"> Two-Factor Authentication - - +