Initial perseus rewrite
continuous-integration/drone/push Build is passing Details

This commit is contained in:
Andinus 2020-03-29 16:10:59 +05:30
parent 7b95d6b80d
commit a6826055bf
Signed by: andinus
GPG Key ID: B67D55D482A799FD
26 changed files with 475 additions and 177 deletions

56
account/addtoken.go Normal file
View File

@ -0,0 +1,56 @@
package account
import (
"log"
"time"
"tildegit.org/andinus/perseus/password"
"tildegit.org/andinus/perseus/storage"
)
// addToken will generate a random token, add it to database and
// return the token.
func (u *User) addToken(db *storage.DB) error {
u.Token = password.RandStr(64)
// Set user id from username.
err := u.GetID(db)
if err != nil {
log.Printf("account/addtoken.go: %s\n",
"failed to get id from username")
return err
}
// Acquire write lock on the database.
db.Mu.Lock()
defer db.Mu.Unlock()
// Start the transaction
tx, err := db.Conn.Begin()
defer tx.Rollback()
if err != nil {
log.Printf("account/addtoken.go: %s\n",
"failed to begin transaction")
return err
}
stmt, err := db.Conn.Prepare(`
INSERT INTO access(id, token, genTime) values(?, ?, ?)`)
if err != nil {
log.Printf("account/addtoken.go: %s\n",
"failed to prepare statement")
return err
}
defer stmt.Close()
_, err = stmt.Exec(u.ID, u.Token, time.Now().UTC())
if err != nil {
log.Printf("account/addtoken.go: %s\n",
"failed to execute statement")
return err
}
tx.Commit()
return err
}

43
account/adduser.go Normal file
View File

@ -0,0 +1,43 @@
package account
import (
"log"
"time"
"tildegit.org/andinus/perseus/storage"
)
// addUser adds the user to record.
func (u *User) addUser(db *storage.DB) error {
// Acquire write lock on the database.
db.Mu.Lock()
defer db.Mu.Unlock()
// Start the transaction
tx, err := db.Conn.Begin()
defer tx.Rollback()
if err != nil {
log.Printf("account/adduser.go: %s\n",
"failed to begin transaction")
return err
}
stmt, err := db.Conn.Prepare(`
INSERT INTO accounts(id, username, hash, regTime) values(?, ?, ?, ?)`)
if err != nil {
log.Printf("account/adduser.go: %s\n",
"failed to prepare statement")
return err
}
defer stmt.Close()
_, err = stmt.Exec(u.ID, u.Username, u.Hash, time.Now().UTC())
if err != nil {
log.Printf("account/adduser.go: %s\n",
"failed to execute statement")
return err
}
tx.Commit()
return err
}

33
account/getid.go Normal file
View File

@ -0,0 +1,33 @@
package account
import (
"log"
"tildegit.org/andinus/perseus/storage"
)
// GetID returns id from username.
func (u *User) GetID(db *storage.DB) error {
// Acquire read lock on database.
db.Mu.RLock()
defer db.Mu.RUnlock()
// Get password for this user from the database.
stmt, err := db.Conn.Prepare("SELECT id FROM accounts WHERE username = ?")
if err != nil {
log.Printf("account/getid.go: %s\n",
"failed to prepare statement")
return err
}
defer stmt.Close()
var id string
err = stmt.QueryRow(u.Username).Scan(&id)
if err != nil {
log.Printf("account/getid.go: %s\n",
"query failed")
}
u.ID = id
return err
}

50
account/login.go Normal file
View File

@ -0,0 +1,50 @@
package account
import (
"log"
"tildegit.org/andinus/perseus/password"
"tildegit.org/andinus/perseus/storage"
)
// Login takes in login details and returns an error. If error doesn't
// equal nil then consider login failed. It will also set the u.Token
// field.
func (u *User) Login(db *storage.DB) error {
// Acquire read lock on the database.
db.Mu.RLock()
// Get password for this user from the database.
stmt, err := db.Conn.Prepare("SELECT hash FROM accounts WHERE username = ?")
if err != nil {
log.Printf("account/login.go: %s\n",
"failed to prepare statement")
return err
}
defer stmt.Close()
var hash string
err = stmt.QueryRow(u.Username).Scan(&hash)
if err != nil {
log.Printf("account/login.go: %s\n",
"query failed")
return err
}
u.Hash = hash
// Check user's password.
err = password.Check(u.Password, u.Hash)
if err != nil {
log.Printf("account/login.go: %s%s\n",
"user login failed, username: ", u.Username)
return err
}
db.Mu.RUnlock()
err = u.addToken(db)
if err != nil {
log.Printf("account/login.go: %s\n",
"addtoken failed")
}
return err
}

49
account/register.go Normal file
View File

@ -0,0 +1,49 @@
package account
import (
"errors"
"log"
"regexp"
"strings"
"tildegit.org/andinus/perseus/password"
"tildegit.org/andinus/perseus/storage"
)
// Register takes in registration details and returns an error. If
// error doesn't equal nil then the registration was unsuccessful.
func (u User) Register(db *storage.DB) error {
var err error
u.ID = password.RandStr(64)
u.Username = strings.ToLower(u.Username)
// Validate username. It must be alphanumeric and less than
// 128 characters.
re := regexp.MustCompile("^[a-zA-Z0-9]*$")
if !re.MatchString(u.Username) {
return errors.New("account/register.go: invalid username")
}
if len(u.Username) > 128 {
return errors.New("account/register.go: username too long")
}
// Validate password
if len(u.Password) < 8 {
return errors.New("account/register.go: password too short")
}
u.Hash, err = password.Hash(u.Password)
if err != nil {
log.Printf("account/register.go: %s\n",
"password.Hash func failed")
return err
}
err = u.addUser(db)
if err != nil {
log.Printf("account/register.go: %s\n",
"addUser func failed")
}
return err
}

View File

@ -1,4 +1,4 @@
package user
package account
// User holds information about the user.
type User struct {
@ -6,4 +6,5 @@ type User struct {
Username string
Password string
Hash string
Token string
}

View File

@ -1,14 +0,0 @@
package auth
import (
"crypto/rand"
"encoding/base64"
)
// genID generates a random id string of length n. Don't forget to
// seed the random number generator otherwise it won't be random.
func genID(n int) string {
b := make([]byte, n/2)
rand.Read(b)
return base64.StdEncoding.EncodeToString(b)
}

View File

@ -1,13 +0,0 @@
package auth
import (
"golang.org/x/crypto/bcrypt"
)
// hashPass takes a string as input and returns the hash of the
// password.
func hashPass(password string) (string, error) {
// 10 is the default cost.
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 10)
return string(bytes), err
}

View File

@ -6,12 +6,12 @@ steps:
- name: vet
image: golang:1.13
commands:
- go vet ./...
- go vet ./...
- name: test
image: golang:1.13
commands:
- go test -v ./auth
- go test -v ./password
---
kind: pipeline
@ -24,4 +24,4 @@ steps:
GOARCH: amd64
GOOS: openbsd
commands:
- go build ./cmd/perseus
- go build ./cmd/perseus

View File

@ -15,25 +15,24 @@ func main() {
db := storage.Init()
defer db.Conn.Close()
envPort, exists := os.LookupEnv("PERSEUS_PORT")
if !exists {
envPort := os.Getenv("PERSEUS_PORT")
if envPort == "" {
envPort = "8080"
}
addr := fmt.Sprintf("127.0.0.1:%s", envPort)
srv := &http.Server{
Addr: addr,
Addr: fmt.Sprintf("127.0.0.1:%s", envPort),
WriteTimeout: 8 * time.Second,
ReadTimeout: 8 * time.Second,
}
http.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) {
web.HandleRegister(w, r, db)
web.RegisterHandler(w, r, db)
})
http.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
web.HandleLogin(w, r, db)
web.LoginHandler(w, r, db)
})
log.Printf("main/main.go: listening on port %s...", envPort)
log.Printf("perseus: listening on port %s...", envPort)
log.Fatal(srv.ListenAndServe())
}

View File

@ -1,6 +0,0 @@
package core
// Version will return the current version.
func Version() string {
return "v0.1.0"
}

81
handler/web/login.go Normal file
View File

@ -0,0 +1,81 @@
package web
import (
"fmt"
"html/template"
"log"
"net/http"
"time"
"tildegit.org/andinus/perseus/account"
"tildegit.org/andinus/perseus/storage"
)
// LoginHandler handles login.
func LoginHandler(w http.ResponseWriter, r *http.Request, db *storage.DB) {
p := Page{}
var err error
t, err := template.ParseFiles("web/templates/login.html")
if err != nil {
log.Printf("web/login.go: 500 Internal Server Error :: %s", err.Error())
http.Error(w, "500 Internal Server Error", http.StatusInternalServerError)
return
}
switch r.Method {
case http.MethodGet:
t.Execute(w, p)
case http.MethodPost:
if err = r.ParseForm(); err != nil {
log.Printf("web/login.go: 400 Bad Request :: %s", err.Error())
http.Error(w, "400 Bad Request", http.StatusBadRequest)
return
}
// Get form values
u := account.User{}
u.Username = r.FormValue("username")
u.Password = r.FormValue("password")
// Perform login
err = u.Login(db)
if err != nil {
log.Printf("web/login.go: %s :: %s",
"login failed",
err.Error())
error := []string{}
error = append(error,
fmt.Sprintf("Login failed"))
p.Error = error
t.Execute(w, p)
return
}
// Login successful, set token
cookie := http.Cookie{
Name: "token",
Value: u.Token,
// Expire the cookie after 16 days from
// current UTC time.
Expires: time.Now().UTC().Add(16 * 24 * time.Hour),
SameSite: http.SameSiteLaxMode,
HttpOnly: true,
}
http.SetCookie(w, &cookie)
success := []string{}
success = append(success,
fmt.Sprintf("Login successful"))
p.Success = success
t.Execute(w, p)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
log.Printf("web/login.go: %v not allowed on %v", r.Method, r.URL)
}
}

View File

@ -1,11 +1,8 @@
package web
import (
"html/template"
)
import "html/template"
// Page holds page information that is sent to all webpages rendered
// by perseus.
// Page holds page information.
type Page struct {
SafeList []template.HTML
List []string

80
handler/web/register.go Normal file
View File

@ -0,0 +1,80 @@
package web
import (
"fmt"
"html/template"
"log"
"net/http"
"strings"
"tildegit.org/andinus/perseus/account"
"tildegit.org/andinus/perseus/storage"
)
// RegisterHandler handles registration.
func RegisterHandler(w http.ResponseWriter, r *http.Request, db *storage.DB) {
p := Page{}
var err error
t, err := template.ParseFiles("web/templates/register.html")
if err != nil {
log.Printf("web/register.go: 500 Internal Server Error :: %s", err.Error())
http.Error(w, "500 Internal Server Error", http.StatusInternalServerError)
return
}
p.Notice = []string{
"Only [a-z] & [0-9] allowed for username",
"Password length must be greater than 8 characters",
}
switch r.Method {
case http.MethodGet:
t.Execute(w, p)
case http.MethodPost:
if err = r.ParseForm(); err != nil {
log.Printf("web/register.go: 400 Bad Request :: %s", err.Error())
http.Error(w, "400 Bad Request", http.StatusBadRequest)
return
}
// Get form values
u := account.User{}
u.Username = r.FormValue("username")
u.Password = r.FormValue("password")
// Perform registration
err = u.Register(db)
if err != nil {
log.Printf("web/register.go: %s :: %s",
"registration failed",
err.Error())
error := []string{}
error = append(error,
fmt.Sprintf("Registration failed"))
// Check if the error was because of username
// not being unique.
if strings.HasPrefix(err.Error(), "UNIQUE constraint failed") {
error = append(error,
fmt.Sprintf("Username not unique"))
}
p.Error = error
} else {
success := []string{}
success = append(success,
fmt.Sprintf("Registration successful"))
p.Success = success
}
t.Execute(w, p)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
log.Printf("web/register.go: %v not allowed on %v", r.Method, r.URL)
}
}

View File

@ -1,14 +1,13 @@
package auth
// Password package contains functions related to passwords.
package password
import (
"golang.org/x/crypto/bcrypt"
)
import "golang.org/x/crypto/bcrypt"
// checkPass takes a string and hash as input and returns an error. If
// Check takes a string and hash as input and returns an error. If
// the error is not nil then the consider the password wrong. We're
// returning error instead of a bool so that we can print failed
// logins to log and logging shouldn't happen here.
func checkPass(password, hash string) error {
func Check(password, hash string) error {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err
}

View File

@ -1,9 +1,9 @@
package auth
package password
import "testing"
// TestCheckPass tests the checkPass function.
func TestCheckPass(t *testing.T) {
// TestCheck tests the Check function.
func TestCheck(t *testing.T) {
var err error
passhash := make(map[string]string)
@ -13,24 +13,22 @@ func TestCheckPass(t *testing.T) {
passhash["Z1S/kQ=="] = "$2a$10$fZ05kKmb7bh4vBLebpK1u.3bUNQ6eeX5ghT/GZaekgS.5bx4.Ru1e"
passhash["J861dQ=="] = "$2a$10$nXb6Btn6n3AWMAUkDh9bFObvQw5V9FLKhfX.E1EzRWgVDuqIp99u2"
// We also check with values generated with hashPass, this may
// fail if hashPass itself fails in that case it's not
// checkPass error so the test shouldn't fail but warning
// should be sent. We use genID func to generate random inputs
// for this test.
// We also check with values generated with Hash, this may
// fail if Hash itself fails in that case it's not Check error
// so the test shouldn't fail but warning should be sent. We
// use genID func to generate random inputs for this test.
for i := 1; i <= 4; i++ {
p := genID(8)
passhash[p], err = hashPass(p)
p := RandStr(8)
passhash[p], err = Hash(p)
if err != nil {
t.Log("hashPass func failed")
}
}
// We test the checkPass func by ranging over all values of
// passhash. We assume that hashPass func returns correct
// hashes.
// We test the Check func by ranging over all values of
// passhash. We assume that Hash func returns correct hashes.
for p, h := range passhash {
err = checkPass(p, h)
err = Check(p, h)
if err != nil {
t.Errorf("password: %s, hash: %s didn't match.",
p, h)

11
password/hash.go Normal file
View File

@ -0,0 +1,11 @@
package password
import "golang.org/x/crypto/bcrypt"
// Hash takes a string as input and returns the hash of the
// password.
func Hash(password string) (string, error) {
// 10 is the default cost.
out, err := bcrypt.GenerateFromPassword([]byte(password), 10)
return string(out), err
}

View File

@ -1,21 +1,21 @@
package auth
package password
import "testing"
// TestHashPass tests the checkPass function.
func TestHashPass(t *testing.T) {
// TestHash tests the Hash function.
func TestHash(t *testing.T) {
var err error
passhash := make(map[string]string)
// We generate random hashes with hashPass, random string is
// generate by genID func.
// We generate random hashes with Hash, random string is
// generate by RandStr func.
for i := 1; i <= 8; i++ {
p := genID(8)
passhash[p], err = hashPass(p)
p := RandStr(8)
passhash[p], err = Hash(p)
// Here we test if the hashPass func runs sucessfully.
if err != nil {
t.Errorf("hashPass func failed for password: %s",
t.Errorf("Hash func failed for password: %s",
p)
}
}
@ -24,7 +24,7 @@ func TestHashPass(t *testing.T) {
// hashes. We assume that checkPass func returns correct
// values.
for p, h := range passhash {
err = checkPass(p, h)
err = Check(p, h)
if err != nil {
t.Errorf("password: %s, hash: %s didn't match.",
p, h)

13
password/randstr.go Normal file
View File

@ -0,0 +1,13 @@
package password
import (
"crypto/rand"
"encoding/base64"
)
// RandStr will return a random base64 encoded string of length n.
func RandStr(n int) string {
b := make([]byte, n/2)
rand.Read(b)
return base64.StdEncoding.EncodeToString(b)
}

View File

@ -1,4 +1,4 @@
package sqlite3
package storage
import (
"database/sql"
@ -17,8 +17,7 @@ func initErr(db *DB, err error) {
log.Fatalf("Initialization Error :: %s", err.Error())
}
// Init initializes a sqlite3 database.
func Init(db *DB) {
func initDB(db *DB) {
var err error
// We set the database path, first the environment variable
@ -36,7 +35,7 @@ func Init(db *DB) {
db.Conn, err = sql.Open("sqlite3", db.Path)
if err != nil {
log.Printf("sqlite3/init.go: %s\n",
"Failed to open database connection")
"failed to open database connection")
initErr(db, err)
}
@ -50,11 +49,11 @@ func Init(db *DB) {
token TEXT NOT NULL,
genTime TEXT NOT NULL);`,
`CREATE TABLE IF NOT EXISTS users (
`CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
type TEXT NOT NULL DEFAULT user,
username VARCHAR(128) NOT NULL UNIQUE,
password TEXT NOT NULL,
hash TEXT NOT NULL,
regTime TEXT NOT NULL);`,
}
@ -67,7 +66,7 @@ func Init(db *DB) {
if err != nil {
log.Printf("sqlite3/init.go: %s\n",
"Failed to prepare statement")
"failed to prepare statement")
log.Println(s)
initErr(db, err)
}
@ -76,7 +75,7 @@ func Init(db *DB) {
stmt.Close()
if err != nil {
log.Printf("sqlite3/init.go: %s\n",
"Failed to execute statement")
"failed to execute statement")
log.Println(s)
initErr(db, err)
}

View File

@ -1,13 +0,0 @@
package sqlite3
import (
"database/sql"
"sync"
)
// DB holds the database connection, mutex & path.
type DB struct {
Path string
Mu *sync.RWMutex
Conn *sql.DB
}

View File

@ -1,17 +1,23 @@
package storage
import (
"database/sql"
"sync"
"tildegit.org/andinus/perseus/storage/sqlite3"
)
// DB holds the database connection, mutex & path.
type DB struct {
Path string
Mu *sync.RWMutex
Conn *sql.DB
}
// Init initializes the database.
func Init() *sqlite3.DB {
var db sqlite3.DB = sqlite3.DB{
func Init() *DB {
db := DB{
Mu: new(sync.RWMutex),
}
sqlite3.Init(&db)
initDB(&db)
return &db
}

View File

@ -1,42 +0,0 @@
package user
import (
"log"
"time"
"tildegit.org/andinus/perseus/storage/sqlite3"
)
// AddUser adds the user to record.
func (u User) AddUser(db *sqlite3.DB) error {
// Acquire write lock on the database.
db.Mu.Lock()
defer db.Mu.Unlock()
// Start the transaction
tx, err := db.Conn.Begin()
if err != nil {
log.Printf("user/adduser.go: %s\n",
"failed to begin transaction")
return err
}
usrStmt, err := db.Conn.Prepare(`
INSERT INTO users(id, username, password, regTime) values(?, ?, ?, ?)`)
if err != nil {
log.Printf("user/adduser.go: %s\n",
"failed to prepare statement")
return err
}
defer usrStmt.Close()
_, err = usrStmt.Exec(u.ID, u.Username, u.Password, time.Now().UTC())
if err != nil {
log.Printf("user/adduser.go: %s\n",
"failed to execute statement")
return err
}
tx.Commit()
return err
}

View File

@ -1,29 +0,0 @@
package user
import (
"log"
"tildegit.org/andinus/perseus/storage/sqlite3"
)
// GetID returns id from username.
func (u *User) GetID(db *sqlite3.DB) error {
// Get password for this user from the database.
stmt, err := db.Conn.Prepare("SELECT id FROM users WHERE username = ?")
if err != nil {
log.Printf("user/getid.go: %s\n",
"failed to prepare statement")
return err
}
defer stmt.Close()
var id string
err = stmt.QueryRow(u.Username).Scan(&id)
if err != nil {
log.Printf("user/getid.go: %s\n",
"query failed")
}
u.ID = id
return err
}

View File

@ -40,7 +40,7 @@
&nbsp;/&nbsp;
<a href="https://andinus.nand.sh/perseus">Perseus</a>
<span style="float:right">
Perseus {{ .Version }}
Perseus {{ if .Version}} {{ . }} {{ end }}
&nbsp;/&nbsp;
<a href="https://tildegit.org/andinus/perseus">
Source Code

View File

@ -40,7 +40,7 @@
&nbsp;/&nbsp;
<a href="https://andinus.nand.sh/perseus">Perseus</a>
<span style="float:right">
Perseus {{ .Version }}
Perseus {{ if .Version}} {{ . }} {{ end }}
&nbsp;/&nbsp;
<a href="https://tildegit.org/andinus/perseus">
Source Code