Merge pull request #222 from OpenFactorioServerManager/new-authentication

New authentication
This commit is contained in:
knoxfighter 2021-01-22 03:01:55 +01:00 committed by GitHub
commit 2d3772642f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 868 additions and 383 deletions

View File

@ -60,9 +60,10 @@ jobs:
docker-push:
needs: [test-npm, test-go]
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
if: github.event_name == 'push' && github.ref != 'refs/heads/master'
steps:
- uses: actions/checkout@v2
- uses: rlespinasse/github-slug-action@v3.x
- uses: actions/setup-node@v1
- uses: actions/setup-go@v2
- uses: docker/setup-buildx-action@v1
@ -78,4 +79,4 @@ jobs:
context: ./docker/
file: ./docker/Dockerfile-local
push: true
tags: ofsm/ofsm:develop
tags: ofsm/ofsm:${{ env.GITHUB_REF_SLUG }}

1
.gitignore vendored
View File

@ -18,3 +18,4 @@ mix-manifest.json
/app/*.css*
.vscode
.env
*.db

View File

@ -1,9 +1,6 @@
{
"username": "admin",
"password": "factorio",
"database_file": "auth.leveldb",
"cookie_encryption_key": "topsecretkey",
"settings_file": "server-settings.json",
"log_file": "factorio-server-manager.log",
"rcon_pass": "factorio_rcon"
"sq_lite_database_file": "sqlite.db",
"log_file": "factorio-server-manager.log",
"rcon_pass": "",
"settings_file": "server-settings.json"
}

View File

@ -1,6 +1,3 @@
ADMIN_USER=admin
ADMIN_PASS=factorio
RCON_PASS=
COOKIE_ENCRYPTION_KEY=
DOMAIN_NAME=<YOUR DOMAIN NAME>
EMAIL_ADDRESS=<YOUR EMAIL ADDRESS>

View File

@ -3,10 +3,7 @@ FROM frolvlad/alpine-glibc
ENV FACTORIO_VERSION=stable \
MANAGER_VERSION=0.9.0 \
ADMIN_USER=admin \
ADMIN_PASS=factorio \
RCON_PASS="" \
COOKIE_ENCRYPTION_KEY=""
RCON_PASS=""
VOLUME /opt/fsm-data /opt/factorio/saves /opt/factorio/mods /opt/factorio/config

View File

@ -2,10 +2,7 @@
FROM frolvlad/alpine-glibc
ENV FACTORIO_VERSION=latest \
ADMIN_USER=admin \
ADMIN_PASS=factorio \
RCON_PASS="" \
COOKIE_ENCRYPTION_KEY=""
RCON_PASS=""
VOLUME /opt/fsm-data /opt/factorio/saves /opt/factorio/mods /opt/factorio/config

View File

@ -9,13 +9,8 @@ and [Docker Compose](https://docs.docker.com/compose/install/) installed.
Copy `docker-compose.yaml` and `.env` files from this repository to somewhere on your server.
Edit values in the `.env` file:
* `ADMIN_USER` (default `admin`): Name of the default user created for FSM UI.
* `ADMIN_PASS` (default `factorio`): Default user password. \
__Important:__ _For security reasons, please change the default user name and password. Never use the defaults._
* `RCON_PASS` (default empty string): Password for Factorio RCON (FSM uses it to communicate with the Factorio server). \
If left empty, a random password will be generated and saved on the first start of the server. You can see the password in `fsm-data/conf.json` file.
* `COOKIE_ENCRYPTION_KEY` (default empty string): The key used to encrypt auth cookie for FSM UI. \
If left empty, a random key will be generated and saved on the first start of the server. You can see the key in `fsm-data/conf.json` file.
* `DOMAIN_NAME` (must be set manually): The domain name where your FSM UI will be available. Must be set,
so [Let's Encrypt](https://letsencrypt.org/) service can issue a valid HTTPS certificate for this domain.
* `EMAIL_ADDRESS` (must be set manually): Your email address. Used only by Let's Encrypt service.
@ -31,7 +26,7 @@ docker-compose up -d
### Simple configuration without HTTPS
If you don't care about HTTPS and want to run just the Factorio Server Manager, or want to run it on local machine you can use `docker-compose.simple.yaml`.
If you don't care about HTTPS and want to run just the Factorio Server Manager, or want to run it on a local machine you can use `docker-compose.simple.yaml`.
Ignore `DOMAIN_NAME` and `EMAIL_ADDREESS` variables in `.env` file and run
```
@ -42,15 +37,16 @@ docker-compose -f docker-compose.simple.yaml up -d
By default container will download the latest version of factorio. If you want to use specific version, you can change
the value of `FACTORIO_VERSION=latest` variable in the `docker-compose.yaml` file.
Any version can be used. Using `latest` will download the newest beta version. Using `stable` will download the newest stable version.
## Accessing the application
Go to the domain specified in your `.env` file in your web browser. If running on localhost host access the application at http://localhost
Go to the domain specified in your `.env` file in your web browser. If running on localhost access the application at http://localhost
### First start
When container starts it begins to dowload Factorio headless server archive, and only after that Factorio Server Manager server starts.
So when Docker Compose writes
When container starts it begins to download Factorio headless server archive, and only after that Factorio Server Manager server starts.
So when docker-compose writes
```
Creating factorio-server-manager ... done
```
@ -68,7 +64,7 @@ Users can be added and deleted on the settings page.
## Updating Factorio
For now you can't update/downgrade the Factorio version from the UI.
For now, you can't update/downgrade the Factorio version from the UI.
You can however do this using docker images while sustaining your security settings and map/modfiles.
@ -80,7 +76,7 @@ After container starts, latest Factorio version will be downloaded and installed
## Security
Authentication is supported in the application but it is recommended to ensure access to the Factorio manager UI is accessible via VPN or internal network.
Authentication is supported in the application, but it is recommended to ensure access to the Factorio manager UI is accessible via VPN or internal network.
## Development
For development purposes it also has the ability to create the docker image from local sourcecode. This is done by running `build.sh` in the `docker` directory. This will delete all old executables and the node_modules directory (runs `make build`). The created docker image will have the tag `factorio-server-manager:dev`.

View File

@ -6,10 +6,7 @@ services:
restart: "unless-stopped"
environment:
- "FACTORIO_VERSION=latest"
- "ADMIN_USER"
- "ADMIN_PASS"
- "RCON_PASS"
- "COOKIE_ENCRYPTION_KEY"
ports:
- "80:80"
- "34197:34197/udp"

View File

@ -6,10 +6,7 @@ services:
restart: "unless-stopped"
environment:
- "FACTORIO_VERSION=latest"
- "ADMIN_USER"
- "ADMIN_PASS"
- "RCON_PASS"
- "COOKIE_ENCRYPTION_KEY"
volumes:
- "./fsm-data:/opt/fsm-data"
- "./factorio-data/saves:/opt/factorio/saves"

View File

@ -3,28 +3,12 @@
init_config() {
jq_cmd='.'
if [ -n $ADMIN_USER ]; then
jq_cmd="${jq_cmd} | .username = \"$ADMIN_USER\""
echo "Admin username is '$ADMIN_USER'"
if [ -n "$RCON_PASS" ]; then
jq_cmd="${jq_cmd} | .rcon_pass = \"$RCON_PASS\""
echo "Factorio rcon password is '$RCON_PASS'"
fi
if [ -n $ADMIN_PASS ]; then
jq_cmd="${jq_cmd} | .password = \"$ADMIN_PASS\""
echo "Admin password is '$ADMIN_PASS'"
fi
echo "IMPORTANT! Please create new user and delete default admin user ASAP."
if [ -z $RCON_PASS ]; then
RCON_PASS="$(random_pass)"
fi
jq_cmd="${jq_cmd} | .rcon_pass = \"$RCON_PASS\""
echo "Factorio rcon password is '$RCON_PASS'"
if [ -z $COOKIE_ENCRYPTION_KEY ]; then
COOKIE_ENCRYPTION_KEY="$(random_pass)"
fi
jq_cmd="${jq_cmd} | .cookie_encryption_key = \"$COOKIE_ENCRYPTION_KEY\""
jq_cmd="${jq_cmd} | .database_file = \"/opt/fsm-data/auth.leveldb\""
jq_cmd="${jq_cmd} | .sq_lite_database_file = \"/opt/fsm-data/sqlite.db\""
jq_cmd="${jq_cmd} | .log_file = \"/opt/fsm-data/factorio-server-manager.log\""
jq "${jq_cmd}" /opt/fsm/conf.json >/opt/fsm-data/conf.json
@ -47,5 +31,5 @@ fi
install_game
cd /opt/fsm && ./factorio-server-manager --conf /opt/fsm-data/conf.json --dir /opt/factorio -port 80
cd /opt/fsm && ./factorio-server-manager --conf /opt/fsm-data/conf.json --dir /opt/factorio --port 80

View File

@ -1,121 +1,235 @@
package api
import (
"encoding/base64"
"github.com/gorilla/sessions"
"github.com/mroote/factorio-server-manager/bootstrap"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"log"
"os"
"sync"
"github.com/apexskier/httpauth"
"net/http"
)
type AuthHTTP struct {
backend httpauth.LeveldbAuthBackend
aaa httpauth.Authorizer
type User bootstrap.User
type Auth struct {
db *gorm.DB
}
type User struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
Email string `json:"email"`
}
var (
sessionStore *sessions.CookieStore
auth Auth
)
var once sync.Once
var instantiated *AuthHTTP
func GetAuth() *AuthHTTP {
once.Do(func() {
Auth := &AuthHTTP{}
config := bootstrap.GetConfig()
_ = Auth.CreateAuth(config.DatabaseFile, config.CookieEncryptionKey)
_ = Auth.CreateOrUpdateUser(config.Username, config.Password, "admin", "")
instantiated = Auth
})
return instantiated
}
func (auth *AuthHTTP) CreateAuth(backendFile string, cookieKey string) error {
func SetupAuth() {
var err error
os.Mkdir(backendFile, 0755)
auth.backend, err = httpauth.NewLeveldbAuthBackend(backendFile)
config := bootstrap.GetConfig()
cookieEncryptionKey, err := base64.StdEncoding.DecodeString(config.CookieEncryptionKey)
if err != nil {
log.Printf("Error creating Auth backend: %s", err)
log.Printf("Error decoding base64 cookie encryption key: %s", err)
panic(err)
}
sessionStore = sessions.NewCookieStore(cookieEncryptionKey)
sessionStore.Options = &sessions.Options{
Path: "/",
Secure: true,
}
auth.db, err = gorm.Open(sqlite.Open(config.SQLiteDatabaseFile), nil)
if err != nil {
log.Printf("Error opening sqlite or goem database: %s", err)
panic(err)
}
err = auth.db.AutoMigrate(&User{})
if err != nil {
log.Printf("Error AutoMigrating gorm database: %s", err)
panic(err)
}
var userCount int64
auth.db.Model(&User{}).Count(&userCount)
if userCount == 0 {
// no user created yet, create a default one
var password = bootstrap.GenerateRandomPassword()
var user User
user.Username = "admin"
user.Password = password
user.Role = "admin"
err := auth.addUser(user)
if err != nil {
log.Printf("Error adding admin user to db: %s", err)
panic(err)
}
log.Println("Created default admin user. Please change it's password as soon as possible.")
log.Printf("Username: %s", user.Username)
log.Printf("Password: %s", password)
}
}
func (a *Auth) checkPassword(username, password string) error {
var user User
result := a.db.Where(&User{Username: username}).Take(&user)
if result.Error != nil {
log.Printf("Error reading user from database: %s", result.Error)
return result.Error
}
decodedHashPw, err := base64.StdEncoding.DecodeString(user.Password)
if err != nil {
log.Printf("Error decoding base64 password: %s", err)
return err
}
roles := make(map[string]httpauth.Role)
roles["user"] = 30
roles["admin"] = 80
auth.aaa, err = httpauth.NewAuthorizer(auth.backend, []byte(cookieKey), "user", roles)
err = bcrypt.CompareHashAndPassword(decodedHashPw, []byte(password))
if err != nil {
log.Printf("Error creating authorizer: %s", err)
if err != bcrypt.ErrMismatchedHashAndPassword {
log.Printf("Unexpected error comparing hash and pw: %s", err)
}
return err
}
// Password correct
return nil
}
func (a *Auth) deleteUser(username string) error {
result := a.db.Model(&User{}).Where(&User{Username: username}).Delete(&User{})
if result.Error != nil {
log.Printf("Error deleting user from database: %s", result.Error)
return result.Error
}
return nil
}
func (a *Auth) hasUser(username string) (bool, error) {
var count int64
result := a.db.Model(&User{}).Where(&User{Username: username}).Count(&count)
if result.Error != nil {
log.Printf("Error checking if user exists in database: %s", result.Error)
return false, result.Error
}
return count == 1, nil
}
func (a *Auth) getUser(username string) (User, error) {
var user User
result := a.db.Model(&User{}).Where(&User{Username: username}).Take(&user)
if result.Error != nil {
log.Printf("Error reading user from database: %s", result.Error)
return User{}, result.Error
}
return user, nil
}
func (a *Auth) listUsers() ([]User, error) {
var users []User
result := a.db.Find(&users)
if result.Error != nil {
log.Printf("Error listing all users in database: %s", result.Error)
return nil, result.Error
}
return users, nil
}
func (a *Auth) addUser(user User) error {
// encrypt password
pwHash, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
if err != nil {
log.Printf("Error generating bcrypt hash from password: %s", err)
return err
}
user.Password = base64.StdEncoding.EncodeToString(pwHash)
// add user to db
result := a.db.Create(&user)
if result.Error != nil {
log.Printf("Error creating user in database: %s", result.Error)
return result.Error
}
return nil
}
func (auth *AuthHTTP) CreateOrUpdateUser(username, password, role, email string) error {
user := httpauth.UserData{Username: username, Role: role, Email: email}
err := auth.backend.SaveUser(user)
if err != nil {
log.Printf("Error saving user: %s", err)
return err
}
err = auth.aaa.Update(nil, nil, username, password, email)
if err != nil {
log.Printf("Error updating user: %s", err)
return err
}
log.Printf("Created/Updated user: %s", user.Username)
return nil
}
func (auth *AuthHTTP) listUsers() ([]User, error) {
var userResponse []User
users, err := auth.backend.Users()
if err != nil {
log.Printf("Error list users: %s", err)
return nil, err
}
for _, user := range users {
u := User{Username: user.Username, Role: user.Role, Email: user.Email}
userResponse = append(userResponse, u)
}
log.Printf("listing users: %v found", len(users))
return userResponse, nil
}
func (auth *AuthHTTP) addUser(username, password, email, role string) error {
user := httpauth.UserData{Username: username, Hash: []byte(password), Email: email, Role: role}
err := auth.backend.SaveUser(user)
if err != nil {
log.Printf("Error creating user %v: %s", user, err)
}
err = auth.aaa.Update(nil, nil, username, password, email)
if err != nil {
log.Printf("Error saving user: %s", err)
return err
}
log.Printf("Added user: %v", user)
return nil
}
func (auth *AuthHTTP) removeUser(username string) error {
err := auth.backend.DeleteUser(username)
if err != nil {
log.Printf("Could not delete user %s, error: %s", username, err)
return err
func (a *Auth) addUserWithHash(user User) error {
// add user to db
result := a.db.Create(&user)
if result.Error != nil {
log.Printf("Error creating user in database: %s", result.Error)
return result.Error
}
return nil
}
func (a *Auth) changePassword(username, password string) error {
var user User
result := a.db.Model(&User{}).Where(&User{Username: username}).Take(&user)
if result.Error != nil {
log.Printf("Error reading user from database: %s", result.Error)
return result.Error
}
hashPW, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Printf("Error generatig bcrypt hash from new password: %s", err)
return err
}
user.Password = base64.StdEncoding.EncodeToString(hashPW)
result = a.db.Save(&user)
if result.Error != nil {
log.Printf("Error resaving user in database: %s", result.Error)
return result.Error
}
return nil
}
// middleware function, that will be called for every request, that has to be authorized
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, err := sessionStore.Get(r, "authentication")
if err != nil {
if session != nil {
session.Options.MaxAge = -1
err2 := session.Save(r, w)
if err2 != nil {
log.Printf("Error deleting cookie: %s", err2)
}
}
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
username, ok := session.Values["username"]
if !ok {
http.Error(w, "Could not read username from sessioncookie", http.StatusUnauthorized)
return
}
hasUser, err := auth.hasUser(username.(string))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if hasUser {
next.ServeHTTP(w, r)
} else {
log.Printf("Unauthenticated request %s %s %s", r.Method, r.Host, r.RequestURI)
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
})
}

View File

@ -4,6 +4,9 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/gorilla/sessions"
"github.com/mroote/factorio-server-manager/bootstrap"
"github.com/mroote/factorio-server-manager/factorio"
"io"
"io/ioutil"
"log"
@ -14,9 +17,6 @@ import (
"sync"
"time"
"github.com/mroote/factorio-server-manager/bootstrap"
"github.com/mroote/factorio-server-manager/factorio"
"github.com/gorilla/mux"
)
@ -53,6 +53,33 @@ func ReadRequestBody(w http.ResponseWriter, r *http.Request, resp *interface{})
return
}
func ReadSessionStore(w http.ResponseWriter, r *http.Request, resp *interface{}, name string) (session *sessions.Session, err error) {
session, err = sessionStore.Get(r, name)
if err != nil {
*resp = fmt.Sprintf("Error reading session cookie [%s]: %s", name, err)
log.Println(*resp)
if session != nil {
session.Options.MaxAge = -1
err2 := session.Save(r, w)
if err2 != nil {
log.Printf("Error deleting session cookie: %s", err2)
}
}
w.WriteHeader(http.StatusUnauthorized)
}
return
}
func SaveSession(w http.ResponseWriter, r *http.Request, resp *interface{}, session *sessions.Session) (err error) {
err = session.Save(r, w)
if err != nil {
*resp = fmt.Sprintf("Error saving session cookie: %s", err)
log.Println(*resp)
w.WriteHeader(http.StatusInternalServerError)
}
return
}
// Lists all save files in the factorio/saves directory
func ListSaves(w http.ResponseWriter, r *http.Request) {
var resp interface{}
@ -417,6 +444,7 @@ func UnmarshallUserJson(body []byte, resp *interface{}, w http.ResponseWriter) (
return
}
// Handler for the Login
func LoginUser(w http.ResponseWriter, r *http.Request) {
var err error
var resp interface{}
@ -439,15 +467,31 @@ func LoginUser(w http.ResponseWriter, r *http.Request) {
}
log.Printf("Logging in user: %s", user.Username)
Auth := GetAuth()
err = Auth.aaa.Login(w, r, user.Username, user.Password, "/")
err = auth.checkPassword(user.Username, user.Password)
if err != nil {
resp = fmt.Sprintf("Error loggin in user: %s, error: %s", user.Username, err)
resp = fmt.Sprintf("Password for user %s wrong", user.Username)
log.Println(resp)
w.WriteHeader(http.StatusUnauthorized)
return
}
session, err := ReadSessionStore(w, r, &resp, "authentication")
if err != nil {
return
}
session.Values["username"] = user.Username
err = SaveSession(w, r, &resp, session)
if err != nil {
return
}
log.Printf("User: %s, logged in successfully", user.Username)
user.Password = ""
resp = user
}
func LogoutUser(w http.ResponseWriter, r *http.Request) {
@ -459,10 +503,16 @@ func LogoutUser(w http.ResponseWriter, r *http.Request) {
}()
w.Header().Set("Content-Type", "application/json;charset=UTF-8")
Auth := GetAuth()
if err = Auth.aaa.Logout(w, r); err != nil {
log.Printf("Error logging out current user")
w.WriteHeader(http.StatusInternalServerError)
session, err := ReadSessionStore(w, r, &resp, "authentication")
if err != nil {
return
}
delete(session.Values, "username")
err = SaveSession(w, r, &resp, session)
if err != nil {
return
}
@ -479,15 +529,24 @@ func GetCurrentLogin(w http.ResponseWriter, r *http.Request) {
}()
w.Header().Set("Content-Type", "application/json;charset=UTF-8")
Auth := GetAuth()
user, err := Auth.aaa.CurrentUser(w, r)
session, err := ReadSessionStore(w, r, &resp, "authentication")
if err != nil {
resp = fmt.Sprintf("Error getting user status: %s, error: %s", user.Username, err)
return
}
username := session.Values["username"].(string)
user, err := auth.getUser(username)
if err != nil {
resp = fmt.Sprintf("Error getting user: %s", err)
log.Println(resp)
w.WriteHeader(http.StatusInternalServerError)
return
}
user.Password = ""
resp = user
}
@ -499,8 +558,8 @@ func ListUsers(w http.ResponseWriter, r *http.Request) {
}()
w.Header().Set("Content-Type", "application/json;charset=UTF-8")
Auth := GetAuth()
users, err := Auth.listUsers()
users, err := auth.listUsers()
if err != nil {
resp = fmt.Sprintf("Error listing users: %s", err)
log.Println(resp)
@ -524,14 +583,12 @@ func AddUser(w http.ResponseWriter, r *http.Request) {
return
}
log.Printf("Adding user: %v", string(body))
user, err := UnmarshallUserJson(body, &resp, w)
if err != nil {
return
}
Auth := GetAuth()
err = Auth.addUser(user.Username, user.Password, user.Email, user.Role)
err = auth.addUser(user)
if err != nil {
resp = fmt.Sprintf("Error in adding user {%s}: %s", user.Username, err)
log.Println(resp)
@ -560,8 +617,8 @@ func RemoveUser(w http.ResponseWriter, r *http.Request) {
if err != nil {
return
}
Auth := GetAuth()
err = Auth.removeUser(user.Username)
err = auth.deleteUser(user.Username)
if err != nil {
resp = fmt.Sprintf("Error in removing user {%s}, error: %s", user.Username, err)
log.Println(resp)
@ -571,6 +628,70 @@ func RemoveUser(w http.ResponseWriter, r *http.Request) {
resp = fmt.Sprintf("User: %s successfully removed.", user.Username)
}
func ChangePassword(w http.ResponseWriter, r *http.Request) {
var resp interface{}
defer func() {
WriteResponse(w, resp)
}()
w.Header().Set("Content-Type", "application/json;charset=UTF-8")
body, err := ReadRequestBody(w, r, &resp)
if err != nil {
return
}
var user struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
NewPasswordConfirm string `json:"new_password_confirmation"`
}
err = json.Unmarshal(body, &user)
if err != nil {
resp = fmt.Sprintf("Unable to parse the request body: %s", err)
log.Println(resp)
w.WriteHeader(http.StatusBadRequest)
return
}
// only allow to change its own password
// get username from session cookie
session, err := ReadSessionStore(w, r, &resp, "authentication")
if err != nil {
return
}
username := session.Values["username"].(string)
// check if password for user is correct
err = auth.checkPassword(username, user.OldPassword)
if err != nil {
resp = fmt.Sprintf("Password for user %s wrong", username)
log.Println(resp)
w.WriteHeader(http.StatusUnauthorized)
return
}
// only run, when confirmation correct
if user.NewPassword != user.NewPasswordConfirm {
resp = fmt.Sprintf("Password confirmation incorrect")
log.Println(resp)
w.WriteHeader(http.StatusBadRequest)
return
}
err = auth.changePassword(username, user.NewPassword)
if err != nil {
resp = fmt.Sprintf("Error changing password: %s", err)
log.Println(resp)
w.WriteHeader(http.StatusInternalServerError)
return
}
resp = true
}
// GetServerSettings returns JSON response of server-settings.json file
func GetServerSettings(w http.ResponseWriter, r *http.Request) {
var resp interface{}

View File

@ -2,7 +2,6 @@ package api
import (
"github.com/mroote/factorio-server-manager/api/websocket"
"log"
"net/http"
"github.com/gorilla/mux"
@ -20,36 +19,40 @@ type Routes []Route
func NewRouter() *mux.Router {
r := mux.NewRouter().StrictSlash(true)
// create subrouter for authenticated calls
sr := r.NewRoute().Subrouter()
sr.Use(AuthMiddleware)
// API subrouter
// Serves all JSON REST handlers prefixed with /api
s := r.PathPrefix("/api").Subrouter()
s.Use(AuthMiddleware)
for _, route := range apiRoutes {
s.Methods(route.Method).
Path(route.Pattern).
Name(route.Name).
Handler(AuthorizeHandler(route.HandlerFunc))
Handler(route.HandlerFunc)
}
// The login handler does not check for authentication.
s.Path("/login").
r.Path("/api/login").
Methods("POST").
Name("LoginUser").
//HandlerFunc(LoginUser)
HandlerFunc(LoginUser)
// Route for initializing websocket connection
// Clients connecting to /ws establish websocket connection by upgrading
// HTTP session.
// Ensure user is logged in with the AuthorizeHandler middleware
r.Path("/ws").
sr.Path("/ws").
Methods("GET").
Name("Websocket").
Handler(
AuthorizeHandler(
http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
websocket.ServeWs(w, r)
},
),
http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
websocket.ServeWs(w, r)
},
),
)
@ -60,38 +63,39 @@ func NewRouter() *mux.Router {
Methods("GET").
Name("Login").
Handler(http.StripPrefix("/login", http.FileServer(http.Dir("./app/"))))
r.Path("/saves").
sr.Path("/saves").
Methods("GET").
Name("Saves").
Handler(AuthorizeHandler(http.StripPrefix("/saves", http.FileServer(http.Dir("./app/")))))
r.Path("/mods").
Handler(http.StripPrefix("/saves", http.FileServer(http.Dir("./app/"))))
sr.Path("/mods").
Methods("GET").
Name("Mods").
Handler(AuthorizeHandler(http.StripPrefix("/mods", http.FileServer(http.Dir("./app/")))))
r.Path("/server-settings").
Handler(http.StripPrefix("/mods", http.FileServer(http.Dir("./app/"))))
sr.Path("/server-settings").
Methods("GET").
Name("Server settings").
Handler(AuthorizeHandler(http.StripPrefix("/server-settings", http.FileServer(http.Dir("./app/")))))
r.Path("/game-settings").
Handler(http.StripPrefix("/server-settings", http.FileServer(http.Dir("./app/"))))
sr.Path("/game-settings").
Methods("GET").
Name("Game settings").
Handler(AuthorizeHandler(http.StripPrefix("/game-settings", http.FileServer(http.Dir("./app/")))))
r.Path("/console").
Handler(http.StripPrefix("/game-settings", http.FileServer(http.Dir("./app/"))))
sr.Path("/console").
Methods("GET").
Name("Console").
Handler(AuthorizeHandler(http.StripPrefix("/console", http.FileServer(http.Dir("./app/")))))
r.Path("/logs").
Handler(http.StripPrefix("/console", http.FileServer(http.Dir("./app/"))))
sr.Path("/logs").
Methods("GET").
Name("Logs").
Handler(AuthorizeHandler(http.StripPrefix("/logs", http.FileServer(http.Dir("./app/")))))
r.Path("/user-management").
Handler(http.StripPrefix("/logs", http.FileServer(http.Dir("./app/"))))
sr.Path("/user-management").
Methods("GET").
Name("User management").
Handler(AuthorizeHandler(http.StripPrefix("/user-management", http.FileServer(http.Dir("./app/")))))
r.Path("/help").
Handler(http.StripPrefix("/user-management", http.FileServer(http.Dir("./app/"))))
sr.Path("/help").
Methods("GET").
Name("Help").
Handler(AuthorizeHandler(http.StripPrefix("/help", http.FileServer(http.Dir("./app/")))))
Handler(http.StripPrefix("/help", http.FileServer(http.Dir("./app/"))))
// catch all route
r.PathPrefix("/").
@ -102,20 +106,6 @@ func NewRouter() *mux.Router {
return r
}
// Middleware returns a http.HandlerFunc which authenticates the users request
// Redirects user to login page if no session is found
func AuthorizeHandler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Auth := GetAuth()
if err := Auth.aaa.Authorize(w, r, true); err != nil {
log.Printf("Unauthenticated request %s %s %s", r.Method, r.Host, r.RequestURI)
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
h.ServeHTTP(w, r)
})
}
// Defines all API REST endpoints
// All routes are prefixed with /api
var apiRoutes = Routes{
@ -209,6 +199,11 @@ var apiRoutes = Routes{
"POST",
"/user/remove",
RemoveUser,
}, {
"ChangePassword",
"POST",
"/user/password",
ChangePassword,
}, {
"GetServerSettings",
"GET",

View File

@ -1,16 +1,17 @@
package bootstrap
import (
"encoding/base64"
"encoding/json"
"fmt"
"github.com/gorilla/securecookie"
"github.com/jessevdk/go-flags"
"log"
"math/rand"
"os"
"path/filepath"
"runtime"
"time"
"github.com/jessevdk/go-flags"
)
type Flags struct {
@ -30,35 +31,34 @@ type Flags struct {
}
type Config struct {
FactorioDir string `json:"factorio_dir"`
FactorioSavesDir string `json:"saves_dir"`
FactorioBaseModDir string `json:"basemod_dir"`
FactorioModsDir string `json:"mods_dir"`
FactorioModPackDir string `json:"mod_pack_dir"`
FactorioConfigFile string `json:"config_file"`
FactorioConfigDir string `json:"config_directory"`
FactorioLog string `json:"logfile"`
FactorioBinary string `json:"factorio_binary"`
FactorioRconPort int `json:"rcon_port"`
FactorioRconPass string `json:"rcon_pass"`
FactorioCredentialsFile string `json:"factorio_credentials_file"`
FactorioIP string `json:"factorio_ip"`
FactorioDir string `json:"factorio_dir,omitempty"`
FactorioSavesDir string `json:"saves_dir,omitempty"`
FactorioBaseModDir string `json:"basemod_dir,omitempty"`
FactorioModsDir string `json:"mods_dir,omitempty"`
FactorioModPackDir string `json:"mod_pack_dir,omitempty"`
FactorioConfigFile string `json:"config_file,omitempty"`
FactorioConfigDir string `json:"config_directory,omitempty"`
FactorioLog string `json:"logfile,omitempty"`
FactorioBinary string `json:"factorio_binary,omitempty"`
FactorioRconPort int `json:"rcon_port,omitempty"`
FactorioRconPass string `json:"rcon_pass,omitempty"`
FactorioCredentialsFile string `json:"factorio_credentials_file,omitempty"`
FactorioIP string `json:"factorio_ip,omitempty"`
FactorioAdminFile string `json:"-"`
ServerIP string `json:"server_ip"`
ServerPort string `json:"server_port"`
MaxUploadSize int64 `json:"max_upload_size"`
Username string `json:"username"`
Password string `json:"password"`
DatabaseFile string `json:"database_file"`
CookieEncryptionKey string `json:"cookie_encryption_key"`
SettingsFile string `json:"settings_file"`
LogFile string `json:"log_file"`
ConfFile string
GlibcCustom string
GlibcLocation string
GlibcLibLoc string
Autostart string
ConsoleCacheSize int `json:"console_cache_size"` // the amount of cached lines, inside the factorio output cache
ServerIP string `json:"server_ip,omitempty"`
ServerPort string `json:"server_port,omitempty"`
MaxUploadSize int64 `json:"max_upload_size,omitempty"`
DatabaseFile string `json:"database_file,omitempty"`
SQLiteDatabaseFile string `json:"sq_lite_database_file,omitempty"`
CookieEncryptionKey string `json:"cookie_encryption_key,omitempty"`
SettingsFile string `json:"settings_file,omitempty"`
LogFile string `json:"log_file,omitempty"`
ConfFile string `json:"-"`
GlibcCustom string `json:"-"`
GlibcLocation string `json:"-"`
GlibcLibLoc string `json:"-"`
Autostart string `json:"-"`
ConsoleCacheSize int `json:"console_cache_size,omitempty"` // the amount of cached lines, inside the factorio output cache
}
var instantiated Config
@ -82,12 +82,78 @@ func GetConfig() Config {
return instantiated
}
func (config *Config) updateConfigFile() {
file, err := os.OpenFile(config.ConfFile, os.O_RDONLY, 0)
failOnError(err, "Error opening file")
defer file.Close()
var conf Config
decoder := json.NewDecoder(file)
decoder.Decode(&conf)
err = file.Close()
failOnError(err, "Error closing json file")
var resave bool
// set cookie encryption key, if empty
// also set it, if the base64 string is not valid
_, base64Err := base64.StdEncoding.DecodeString(conf.CookieEncryptionKey)
if conf.CookieEncryptionKey == "" || conf.CookieEncryptionKey == "topsecretkey" || base64Err != nil {
log.Println("CookieEncryptionKey invalid or empty, create new random one")
randomKey := securecookie.GenerateRandomKey(32)
conf.CookieEncryptionKey = base64.StdEncoding.EncodeToString(randomKey)
resave = true
}
if conf.FactorioRconPass == "" || conf.FactorioRconPass == "factorio_rcon" {
// password is "factorio" .. change it
conf.FactorioRconPass = GenerateRandomPassword()
log.Println("Rcon password default one or empty, generated new one:")
log.Printf("Password: %s", conf.FactorioRconPass)
resave = true
}
if conf.DatabaseFile != "" {
// Migrate leveldb to sqlite
// set new db name
// just rename the file from the old path
dbFileDir := filepath.Dir(conf.DatabaseFile)
conf.SQLiteDatabaseFile = filepath.Join(dbFileDir, "sqlite.db")
MigrateLevelDBToSqlite(conf.DatabaseFile, conf.SQLiteDatabaseFile)
// remove old db name
conf.DatabaseFile = ""
resave = true
}
if resave {
// save json file again
file, err = os.OpenFile(config.ConfFile, os.O_WRONLY, 0)
failOnError(err, "Error opening file for writing")
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", "\t")
err = encoder.Encode(conf)
failOnError(err, "Error encoding JSON config file.")
}
}
// Loads server configuration files
// JSON config file contains default values,
// config file will overwrite any provided flags
func (config *Config) loadServerConfig() {
file, err := os.Open(config.ConfFile)
// load and potentially update conf.json
config.updateConfigFile()
file, err := os.OpenFile(config.ConfFile, os.O_RDWR, 0)
failOnError(err, "Error loading config file.")
defer file.Close()
decoder := json.NewDecoder(file)
err = decoder.Decode(&config)
@ -101,6 +167,7 @@ func (config *Config) loadServerConfig() {
config.FactorioBaseModDir = filepath.Join(config.FactorioDir, "data", "base")
}
// Set random port as rconPort
config.FactorioRconPort = randomPort()
}

129
src/bootstrap/user.go Normal file
View File

@ -0,0 +1,129 @@
package bootstrap
import (
"encoding/base64"
"encoding/json"
"github.com/syndtr/goleveldb/leveldb"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"log"
"math/rand"
"os"
)
type User struct {
gorm.Model
Username string `json:"username",gorm:"uniqueIndex,not null"`
Password string `json:"password",gorm:"not null"`
Role string `json:"role",gorm:"not null"`
Email string `json:"email"`
}
func MigrateLevelDBToSqlite(oldDBFile, newDBFile string) {
oldDB, err := leveldb.OpenFile(oldDBFile, nil)
if err != nil {
log.Printf("Error opening old leveldb: %s", err)
panic(err)
}
defer oldDB.Close()
newDB, err := gorm.Open(sqlite.Open(newDBFile), nil)
if err != nil {
log.Printf("Error open sqlite and gorm: %s", err)
panic(err)
}
defer func() {
db, err2 := newDB.DB()
if err2 != nil {
log.Printf("Error getting real DB from gorm: %s", err2)
}
if db != nil {
err2 = db.Close()
if err2 != nil {
log.Printf("Error closing real DB of gorm: %s", err2)
panic(err2)
}
}
}()
err = newDB.AutoMigrate(&User{})
if err != nil {
log.Printf("Error autoMigrating sqlite database with user: %s", err)
panic(err)
}
oldUserData, err := oldDB.Get([]byte("httpauth::userdata"), nil)
if err != nil {
log.Printf("Error getting `httpauth::userdata` from leveldb: %s", err)
panic(err)
}
var migrationData map[string]struct {
Username string
Email string
Hash string
Role string
}
err = json.Unmarshal(oldUserData, &migrationData)
if err != nil {
log.Printf("Error unmarshalling old ")
panic(err)
}
for _, datum := range migrationData {
// check if password is "factorio", which was the default password in the old system
decodedHash, err := base64.StdEncoding.DecodeString(datum.Hash)
if err != nil {
log.Printf("Error decoding base64 hash: %s", err)
panic(err)
}
err = bcrypt.CompareHashAndPassword(decodedHash, []byte("factorio"))
if err == nil {
// password is "factorio" .. change it
newPassword := GenerateRandomPassword()
bcryptPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
log.Printf("Error generating has from password: %s", err)
panic(err)
}
datum.Hash = base64.StdEncoding.EncodeToString(bcryptPassword)
log.Println(`Migrated user in database. It still had default password "factorio" set. New credentials:`)
log.Printf("Username: %s", datum.Username)
log.Printf("Password: %s", newPassword)
}
user := &User{
Username: datum.Username,
Password: datum.Hash,
Role: datum.Role,
Email: datum.Email,
}
newDB.Create(user)
}
oldDB.Close()
// delete oldDB
log.Println("Deleting old leveldb database.")
err = os.RemoveAll(oldDBFile)
if err != nil {
log.Printf("Error removing leveldb: %s", err)
panic(err)
}
}
var randLetters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
func GenerateRandomPassword() string {
pass := make([]rune, 24)
for i := range pass {
pass[i] = randLetters[rand.Intn(len(randLetters))]
}
return string(pass)
}

View File

@ -3,23 +3,23 @@ module github.com/mroote/factorio-server-manager
go 1.13
require (
github.com/apexskier/httpauth v1.3.2
github.com/go-ini/ini v1.49.0
github.com/go-sql-driver/mysql v1.4.1 // indirect
github.com/golang/protobuf v1.3.1 // indirect
github.com/gorilla/mux v1.7.3
github.com/gorilla/sessions v1.2.0 // indirect
github.com/gorilla/securecookie v1.1.1
github.com/gorilla/sessions v1.2.0
github.com/gorilla/websocket v1.4.1
github.com/hpcloud/tail v1.0.0
github.com/jessevdk/go-flags v1.4.0
github.com/joho/godotenv v1.3.0
github.com/lib/pq v1.2.0 // indirect
github.com/majormjr/rcon v0.0.0-20120923215419-8fbb8268b60a
github.com/mattn/go-sqlite3 v1.11.0 // indirect
github.com/smartystreets/goconvey v0.0.0-20190731233626-505e41936337 // indirect
github.com/stretchr/testify v1.6.1
github.com/syndtr/goleveldb v1.0.0 // indirect
golang.org/x/crypto v0.0.0-20191029031824-8986dd9e96cf // indirect
google.golang.org/appengine v1.6.5 // indirect
github.com/syndtr/goleveldb v1.0.0
golang.org/x/crypto v0.0.0-20191029031824-8986dd9e96cf
golang.org/x/net v0.0.0-20190603091049-60506f45cf65 // indirect
golang.org/x/text v0.3.2 // indirect
gopkg.in/ini.v1 v1.49.0 // indirect
gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22 // indirect
gorm.io/driver/sqlite v1.1.4
gorm.io/gorm v1.20.11
)

View File

@ -1,13 +1,9 @@
github.com/apexskier/httpauth v1.3.2 h1:PHwrq/eBRBLIrUthchpbDVTVR/ofBrj2LUcukCRhfXw=
github.com/apexskier/httpauth v1.3.2/go.mod h1:aEHd6x648VCocEK0vTsPKkjJ1sBPab3Z4V4MJs9YZAE=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/go-ini/ini v1.49.0 h1:ymWFBUkwN3JFPjvjcJJ5TSTwh84M66QrH+8vOytLgRY=
github.com/go-ini/ini v1.49.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg=
@ -28,16 +24,18 @@ github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.1 h1:g39TucaRWyV3dwDO++eEc6qf8TVIQ/Da48WmqjZ3i7E=
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/majormjr/rcon v0.0.0-20120923215419-8fbb8268b60a h1:rXEd7/SA5sJvgl2zxM/nNblGa9kkpnx2phQATclw9Xk=
github.com/majormjr/rcon v0.0.0-20120923215419-8fbb8268b60a/go.mod h1:RNVV4T548mxgb643odZHF+pWh/YmGLxiKvtkbI1vRYE=
github.com/mattn/go-sqlite3 v1.11.0 h1:LDdKkqtYlom37fkvqs8rMPFKAMe8+SgjbwZ6ex1/A/Q=
github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.14.5 h1:1IdxlwTNazvbKJQSxoJ5/9ECbEeaTTyeU7sEAZ5KKTQ=
github.com/mattn/go-sqlite3 v1.14.5/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@ -75,19 +73,20 @@ golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/ini.v1 v1.49.0 h1:MW0aLMiezbm/Ray0gJJ+nQFE2uOC9EpK2p5zPN3NqpM=
gopkg.in/ini.v1 v1.49.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22 h1:VpOs+IwYnYBaFnrNAeB8UUWtL3vEUnzSCL1nVjPhqrw=
gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.1.4 h1:PDzwYE+sI6De2+mxAneV9Xs11+ZyKV6oxD3wDGkaNvM=
gorm.io/driver/sqlite v1.1.4/go.mod h1:mJCeTFr7+crvS+TRnWc5Z3UvwxUN1BGBLMrf5LA9DYw=
gorm.io/gorm v1.20.7/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.20.11 h1:jYHQ0LLUViV85V8dM1TP9VBBkfzKTnuTXDjYObkI6yc=
gorm.io/gorm v1.20.11/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=

View File

@ -25,7 +25,7 @@ func main() {
}
// Initialize authentication system
api.GetAuth()
api.SetupAuth()
// Initialize HTTP router -- also initializes websocket
router := api.NewRouter()

View File

@ -32,14 +32,12 @@ const App = () => {
}
}
const handleAuthenticationStatus = useCallback(async () => {
const status = await user.status();
if (status?.Username) {
const handleAuthenticationStatus = useCallback(async (status) => {
if (status?.username) {
setIsAuthenticated(true);
await updateServerStatus();
await updateServerStatus()
socket.emit('server status subscribe');
socket.on('server_status', updateServerStatus)
socket.on('server_status', updateServerStatus);
}
},[]);
@ -47,7 +45,6 @@ const App = () => {
const loggedOut = await user.logout();
if (loggedOut) {
setIsAuthenticated(false);
history.push('/login');
}
}, []);

View File

@ -0,0 +1,14 @@
import React from "react"
const Error = ({error, message}) => {
if (error) {
return (
<span className="block text-red">
{message}
</span>
)
}
return null
}
export default Error

View File

@ -6,15 +6,21 @@ export const Flash = () => {
let [message, setMessage] = useState('');
let [color, setColor] = useState('');
let flashListener = ({message, color}) => {
setVisibility(true);
setMessage(message);
setColor(color);
setTimeout(() => {
setVisibility(false);
}, 4000);
}
useEffect(() => {
Bus.addListener('flash', ({message, color}) => {
setVisibility(true);
setMessage(message);
setColor(color);
setTimeout(() => {
setVisibility(false);
}, 4000);
});
Bus.addListener('flash', flashListener);
return function () {
Bus.removeListener('flash', flashListener);
}
}, []);
return (

View File

@ -11,22 +11,26 @@ import {Flash} from "../components/Flash";
const Login = ({handleLogin}) => {
const {register, handleSubmit, errors} = useForm();
const history = useHistory();
const location = useLocation()
const location = useLocation();
const onSubmit = async data => {
const loginAttempt = await user.login(data)
if (loginAttempt?.Username) {
await handleLogin();
history.push('/');
try {
const loginAttempt = await user.login(data)
if (loginAttempt?.username) {
await handleLogin(loginAttempt);
history.push('/');
}
} catch (e) {
window.flash("Login failed. Username or Password wrong.", "red");
}
};
// on mount check if user is authenticated
useEffect(() => {
(async () => {
const status = await user.status()
if (status?.Username) {
await handleLogin();
const status = await user.status();
if (status?.username) {
await handleLogin(status);
history.push(location?.state?.from || '/');
}
})();

View File

@ -2,6 +2,7 @@ import Panel from "../../components/Panel";
import React, {useCallback, useEffect, useState} from "react";
import user from "../../../api/resources/user";
import CreateUserForm from "./components/CreateUserForm";
import ChangePasswordForm from "./components/ChangePasswordForm"
import {faTrashAlt} from "@fortawesome/free-solid-svg-icons";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
@ -55,9 +56,15 @@ const UserManagement = () => {
}
className="mb-4"
/>
<Panel
title="Change Password"
content={<ChangePasswordForm/>}
className="mb-4"
/>
<Panel
title="Create User"
content={<CreateUserForm updateUserList={updateList}/>}
className="mb-4"
/>
</>
)

View File

@ -0,0 +1,55 @@
import {useForm} from "react-hook-form";
import React from "react";
import user from "../../../../api/resources/user";
import Button from "../../../components/Button";
import Label from "../../../components/Label";
import Input from "../../../components/Input";
import Error from "../../../components/Error";
const ChangePasswordForm = () => {
const {register, handleSubmit, errors, watch} = useForm();
const password = watch('new_password');
const onSubmit = async (data) => {
const res = await user.changePassword(data);
if (res) {
// Update successful
window.flash("Password changed", "green")
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-4">
<Label htmlFor="old_password" text="Old Password"/>
<Input inputRef={register({required: true})}
name="old_password"
type="password"
placeholder="Old Password"
/>
<Error error={errors.old_password} message="Old Password is required"/>
</div>
<div className="mb-4">
<Label htmlFor="new_password" text="New Password"/>
<Input inputRef={register({required: true})}
name="new_password"
type="password"
placeholder="New Password"
/>
<Error error={errors.new_password} message="New Password is required"/>
</div>
<div className="mb-4">
<Label htmlFor="new_password_confirmation" text="New Password Confirmation"/>
<Input inputRef={register({required: true})}
name="new_password_confirmation"
type="password"
placeholder="New Password"
/>
<Error error={errors.new_password_confirmation} message="New Password Confirmation is required"/>
</div>
<Button isSubmit={true} type="success">Change</Button>
</form>
)
}
export default ChangePasswordForm

View File

@ -2,6 +2,9 @@ import {useForm} from "react-hook-form";
import React from "react";
import user from "../../../../api/resources/user";
import Button from "../../../components/Button";
import Label from "../../../components/Label";
import Input from "../../../components/Input";
import Error from "../../../components/Error";
const CreateUserForm = ({updateUserList}) => {
@ -9,7 +12,7 @@ const CreateUserForm = ({updateUserList}) => {
const password = watch('password');
const onSubmit = async (data) => {
const res = user.add(data);
const res = await user.add(data);
if (res) {
updateUserList()
}
@ -18,61 +21,53 @@ const CreateUserForm = ({updateUserList}) => {
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-4">
<label className="block text-white text-sm font-bold mb-2" htmlFor="username">
Username
</label>
<input className="shadow appearance-none border w-full py-2 px-3 text-black"
ref={register({required: true})}
id="username"
<Label htmlFor="username" text="Username"/>
<Input inputRef={register({required: true})}
name="username"
type="text" placeholder="Username"/>
{errors.username && <span className="block text-red">Username is required</span>}
type="text"
placeholder="Username"
/>
<Error error={errors.username} message="Username is required"/>
</div>
<div className="mb-4">
<label className="block text-white text-sm font-bold mb-2" htmlFor="username">
Role
</label>
<Label htmlFor="role" text="Role" />
<input className="shadow appearance-none border w-full py-2 px-3 text-black"
ref={register({required: true})}
id="role"
name="role"
value="admin"
disabled={true}
type="text" placeholder="Role"/>
{errors.role && <span className="block text-red">Role is required</span>}
type="text"
placeholder="Role"
/>
<Error error={errors.role} message="Role is required"/>
</div>
<div className="mb-4">
<label className="block text-white text-sm font-bold mb-2" htmlFor="username">
Email
</label>
<input className="shadow appearance-none border w-full py-2 px-3 text-black"
ref={register({required: true})}
id="email"
<Label htmlFor="email" text="Email"/>
<Input inputRef={register({required: true})}
name="email"
type="text" placeholder="Email"/>
{errors.email && <span className="block text-red">Email is required</span>}
type="email"
placeholder="Email"
/>
<Error error={errors.email} message="Email is required"/>
</div>
<div className="mb-4">
<label className="block text-white text-sm font-bold mb-2" htmlFor="username">
Password
</label>
<input className="shadow appearance-none border w-full py-2 px-3 text-black"
ref={register({required: true})}
id="password"
<Label htmlFor="password" text="Password"/>
<Input inputRef={register({required: true})}
name="password"
type="password" placeholder="Password"/>
{errors.password && <span className="block text-red">Password is required</span>}
type="password"
placeholder="Password"
/>
<Error error={errors.password} message="Password is required"/>
</div>
<div className="mb-4">
<label className="block text-white text-sm font-bold mb-2" htmlFor="username">
Password Confirmation
</label>
<input className="shadow appearance-none border w-full py-2 px-3 text-black"
ref={register({required: true, validate: confirmation => confirmation === password})}
id="password_confirmation"
<Label htmlFor="password_confirmation" text="Password Confirmation"/>
<Input inputRef={register({required: true, validate: conformation => conformation === password})}
name="password_confirmation"
type="password" placeholder="Password Confirmation"/>
{errors.password_confirmation && <span className="block text-red">Password Confirmation is required and must match the Password</span>}
type="password"
placeholder="Password Confirmation"
/>
<Error error={errors.password_confirmation} message="Password Confirmation is required and must match the Password"/>
</div>
<Button isSubmit={true} type="success">Save</Button>
</form>

View File

@ -24,5 +24,9 @@ export default {
delete: async (username) => {
const response = await client.post('/api/user/remove', JSON.stringify({username}));
return response.data;
},
changePassword: async data => {
const response = await client.post('/api/user/password', data);
return response.data;
}
}

View File

@ -1,69 +1,83 @@
import EventEmitter from "events";
const ws_scheme = window.location.protocol === "https:" ? "wss" : "ws";
const socket = new WebSocket(ws_scheme + "://" + window.location.host + "/ws");
const bus = new EventEmitter();
bus.on('log subscribe', () => {
socket.send(
JSON.stringify(
{
room_name: "",
controls: {
type: "subscribe",
value: "gamelog"
}
}
)
);
});
const ws_scheme = window.location.protocol === "https:" ? "wss" : "ws";
bus.on('log unsubscribe', () => {
socket.send(
JSON.stringify(
{
room_name: "",
controls: {
type: "unsubscribe",
value: "gamelog"
}
}
)
);
})
function connect() {
const socket = new WebSocket(ws_scheme + "://" + window.location.host + "/ws");
bus.on('server status subscribe', () => {
socket.send(
JSON.stringify(
{
room_name: "",
controls: {
type: "subscribe",
value: "server_status"
bus.on('log subscribe', () => {
socket.send(
JSON.stringify(
{
room_name: "",
controls: {
type: "subscribe",
value: "gamelog"
}
}
}
)
);
});
)
);
});
bus.on('command send', command => {
socket.send(
JSON.stringify(
{
room_name: "",
controls: {
type: "command",
value: command
bus.on('log unsubscribe', () => {
socket.send(
JSON.stringify(
{
room_name: "",
controls: {
type: "unsubscribe",
value: "gamelog"
}
}
}
)
);
});
)
);
})
socket.onmessage = e => {
const {room_name, message} = JSON.parse(e.data);
bus.emit(room_name, message);
bus.on('server status subscribe', () => {
socket.send(
JSON.stringify(
{
room_name: "",
controls: {
type: "subscribe",
value: "server_status"
}
}
)
);
});
bus.on('command send', command => {
socket.send(
JSON.stringify(
{
room_name: "",
controls: {
type: "command",
value: command
}
}
)
);
});
socket.onmessage = e => {
const {room_name, message} = JSON.parse(e.data);
bus.emit(room_name, message);
}
socket.onerror = e => {
socket.close();
}
socket.onclose = e => {
// reconnect after 5 seconds
setTimeout(connect, 5000);
}
}
connect();
export default bus;