mirror of
https://github.com/mickael-kerjean/filestash.git
synced 2026-10-19 16:14:39 +00:00
feature (plg_authenticate_simple): auth facade with a gui
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_authenticate_openid"
|
||||
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_authenticate_passthrough"
|
||||
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_authenticate_saml"
|
||||
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_authenticate_simple"
|
||||
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_backend_artifactory"
|
||||
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_backend_backblaze"
|
||||
_ "github.com/mickael-kerjean/filestash/server/plugin/plg_backend_dav"
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package plg_authenticate_simple
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
. "github.com/mickael-kerjean/filestash/server/common"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type SimpleAuth struct{}
|
||||
|
||||
func (this SimpleAuth) Setup() Form {
|
||||
n := 0
|
||||
a := 0
|
||||
if users, err := getUsers(); err == nil {
|
||||
n = len(users)
|
||||
for i := range users {
|
||||
if users[i].Disabled == false {
|
||||
a += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return Form{
|
||||
Elmnts: []FormElement{
|
||||
{
|
||||
Name: "banner",
|
||||
Type: "hidden",
|
||||
Description: `Manage your team members and their account permissions by visiting [/admin/simple-user-management](/admin/simple-user-management).
|
||||
|
||||
= STATS =
|
||||
- total users: ` + fmt.Sprintf("%d", n) + `
|
||||
- active users: ` + fmt.Sprintf("%d", a) + `
|
||||
`,
|
||||
},
|
||||
{
|
||||
Name: "type",
|
||||
Type: "hidden",
|
||||
Value: "simple",
|
||||
},
|
||||
{
|
||||
Name: "notification_subject",
|
||||
Type: "text",
|
||||
},
|
||||
{
|
||||
Name: "notification_body",
|
||||
Type: "long_text",
|
||||
Placeholder: `Hello,
|
||||
|
||||
Your account to Filestash was created by an administrator. You can access
|
||||
it via http://demo.filestash.app.
|
||||
|
||||
Your password is: {{ .password }}
|
||||
The roles assigned to you: {{ .role }}
|
||||
|
||||
Cheers!`,
|
||||
},
|
||||
{
|
||||
Name: "db",
|
||||
Type: "hidden",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (this SimpleAuth) EntryPoint(idpParams map[string]string, req *http.Request, res http.ResponseWriter) error {
|
||||
getFlash := func() string {
|
||||
c, err := req.Cookie("flash")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
http.SetCookie(res, &http.Cookie{
|
||||
Name: "flash",
|
||||
MaxAge: -1,
|
||||
Path: "/",
|
||||
})
|
||||
return fmt.Sprintf(`<p class="flash">%s</p>`, c.Value)
|
||||
}
|
||||
res.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
res.WriteHeader(http.StatusOK)
|
||||
res.Write([]byte(Page(`
|
||||
<form action="` + WithBase("/api/session/auth/") + `" method="post" class="component_middleware">
|
||||
<label>
|
||||
<input type="text" name="email" value="" placeholder="Email" />
|
||||
</label>
|
||||
<label>
|
||||
<input type="password" name="password" value="" placeholder="Password" />
|
||||
</label>
|
||||
<button>CONNECT</button>
|
||||
` + getFlash() + `
|
||||
<style>
|
||||
.flash{ color: #f26d6d; font-weight: bold; }
|
||||
form { padding-top: 10vh; }
|
||||
</style>
|
||||
</form>`)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (this SimpleAuth) Callback(formData map[string]string, idpParams map[string]string, res http.ResponseWriter) (map[string]string, error) {
|
||||
users, err := getUsers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range users {
|
||||
if users[i].Email != formData["email"] {
|
||||
continue
|
||||
}
|
||||
if err = bcrypt.CompareHashAndPassword([]byte(users[i].Password), []byte(formData["password"])); err != nil {
|
||||
break
|
||||
}
|
||||
if users[i].Disabled == true {
|
||||
http.SetCookie(res, &http.Cookie{
|
||||
Name: "flash",
|
||||
Value: "Account is disabled",
|
||||
MaxAge: 1,
|
||||
Path: "/",
|
||||
})
|
||||
Log.Warning("plg_authentication_simple::auth action=authenticate email=%s err=disabled", users[i].Email)
|
||||
return nil, ErrAuthenticationFailed
|
||||
}
|
||||
return map[string]string{
|
||||
"user": users[i].Email,
|
||||
"password": users[i].Password,
|
||||
"role": users[i].Role,
|
||||
}, nil
|
||||
}
|
||||
|
||||
http.SetCookie(res, &http.Cookie{
|
||||
Name: "flash",
|
||||
Value: "Inalid username or password",
|
||||
MaxAge: 1,
|
||||
Path: "/",
|
||||
})
|
||||
return nil, ErrAuthenticationFailed
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package plg_authenticate_simple
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
. "github.com/mickael-kerjean/filestash/server/common"
|
||||
)
|
||||
|
||||
func getPluginData() (pluginConfig, error) {
|
||||
var cfg pluginConfig
|
||||
if Config.Get("middleware.identity_provider.type").String() != "simple" {
|
||||
Log.Warning("plg_authenticate_simple::disable msg=middleware_is_not_enabled")
|
||||
return cfg, ErrMissingDependency
|
||||
}
|
||||
if err := json.Unmarshal(
|
||||
[]byte(Config.Get("middleware.identity_provider.params").String()),
|
||||
&cfg,
|
||||
); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
if cfg.DB != "" {
|
||||
if err := json.Unmarshal(
|
||||
[]byte(cfg.DB),
|
||||
&cfg.Users,
|
||||
); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func savePluginData(cfg pluginConfig) error {
|
||||
if Config.Get("middleware.identity_provider.type").String() != "simple" {
|
||||
Log.Warning("plg_authenticate_simple::disable msg=middleware_is_not_enabled")
|
||||
return ErrMissingDependency
|
||||
}
|
||||
a, err := json.Marshal(cfg.Users)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.DB = string(a)
|
||||
b, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Config.Get("middleware.identity_provider.params").Set(string(b))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package plg_authenticate_simple
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"text/template"
|
||||
|
||||
. "github.com/mickael-kerjean/filestash/server/common"
|
||||
)
|
||||
|
||||
//go:embed handler.html
|
||||
var PAGE string
|
||||
|
||||
func UserManagementHandler(ctx *App, res http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == http.MethodDelete {
|
||||
if err := removeUser(req.FormValue("email")); err != nil {
|
||||
SendErrorResult(res, err)
|
||||
return
|
||||
}
|
||||
SendSuccessResult(res, nil)
|
||||
return
|
||||
}
|
||||
|
||||
currentUser := User{}
|
||||
users, err := getUsers()
|
||||
if err != nil {
|
||||
SendErrorResult(res, err)
|
||||
return
|
||||
}
|
||||
email := formatEmail(req.URL.Query().Get("email"))
|
||||
if email == "" {
|
||||
email = formatEmail(req.FormValue("email"))
|
||||
}
|
||||
if email != "" {
|
||||
for i := range users {
|
||||
if users[i].Email == email {
|
||||
currentUser = users[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.Method == http.MethodPost {
|
||||
user := User{
|
||||
Email: email,
|
||||
Password: formatPassword(req.FormValue("password")),
|
||||
Role: formatRole(req.FormValue("role")),
|
||||
Disabled: false,
|
||||
}
|
||||
if req.FormValue("disabled") == "on" {
|
||||
user.Disabled = true
|
||||
}
|
||||
redirectURI := req.URL.String()
|
||||
fn := createUser
|
||||
if currentUser.Email != "" {
|
||||
fn = updateUser
|
||||
redirectURI = req.URL.Path
|
||||
}
|
||||
if err := fn(user); err != nil {
|
||||
SendErrorResult(res, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(res, req, redirectURI, http.StatusSeeOther)
|
||||
if currentUser.Email == "" {
|
||||
go sendInvitateMail(user)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
referer := ""
|
||||
if u, err := url.Parse(req.Header.Get("referer")); err == nil {
|
||||
referer = u.Path
|
||||
}
|
||||
template.
|
||||
Must(template.New("app").Parse(Page(PAGE))).
|
||||
Execute(res, struct {
|
||||
Users []User
|
||||
CurrentUser User
|
||||
Referer string
|
||||
}{
|
||||
Users: users,
|
||||
CurrentUser: currentUser,
|
||||
Referer: referer,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<style>
|
||||
html { height: initial; }
|
||||
body { max-width: 800px; margin: 0 auto; text-align: left; padding-left: 10px; padding-right: 10px; }
|
||||
h1 { margin-top: 10px; margin-bottom: 0; font-size: 2.3rem; }
|
||||
p { margin: 7px 0; }
|
||||
table { width: 100%; margin: 30px auto 50px auto; padding: 0; }
|
||||
table .disabled td:not([onclick]) { text-decoration: line-through; }
|
||||
table td.action { color: white; background: rgba(0, 0, 0, 0.8); border-radius: 30px; font-weight: bold; font-size: 0.7rem; cursor: pointer; }
|
||||
form { width: 100%; padding: 0; }
|
||||
thead { background: rgba(0, 0, 0, 0.04); color: rgba(0, 0, 0, 0.6); font-weight: normal; }
|
||||
tbody tr:hover { background: rgba(255, 255, 255, 0.7); }
|
||||
th { font-weight: normal; }
|
||||
th, td { padding: 5px 10px; }
|
||||
input[disabled], .common_response_page button[disabled] { background: rgba(0,0,0,0.1); }
|
||||
dialog { border: none !important; border-radius: 10px; box-shadow: 0 0 #0000, 0 0 #0000, 0 25px 50px -12px rgba(0, 0, 0, 0.25); padding: 20px 40px 35px 40px; width: 450px; background: #f2f3f5; z-index: 5; }
|
||||
dialog[open] { animation: dialogin 0.2s ease forwards; }
|
||||
dialog[open] ~ .backdrop { display: block; }
|
||||
@keyframes dialogin{
|
||||
0%{ opacity:0; transform: translateY(5px); }
|
||||
100%{ opacity:1; transform: translateY(0); }
|
||||
}
|
||||
dialog h1 { padding: 0; margin: 5px 0 20px 0; }
|
||||
.backdrop { display: none; position: absolute; top: 0; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,0.4); z-index: 2; }
|
||||
.transparency { opacity: 0.8; }
|
||||
.center { text-align: center; }
|
||||
.pointer { cursor: pointer; }
|
||||
.button__round { border: 1px solid #313538; display: inline-block; width: 25px; height: 25px; line-height: 25px; text-align: center; border-radius: 50%; padding: 2px; box-shadow: 2px 2px rgba(0, 0, 0, 0.2); }
|
||||
.banner { background: rgba(0, 0, 0, 0.02); border: 2px dashed rgba(0, 0, 0, 0.05); padding: 50px 0; text-align: center; margin-top: 30px; font-size: 1.2rem; color: rgba(0, 0, 0, 0.3); }
|
||||
h1 span.pointer { float: right; }
|
||||
#modal-close { transform: rotate(45deg); }
|
||||
#back { position: absolute; top: 10px; left: 10px; color: inherit; text-decoration: none; }
|
||||
</style>
|
||||
|
||||
<dialog {{ if ne .CurrentUser.Email "" }}open{{ end }}>
|
||||
<h1>
|
||||
{{ if eq .CurrentUser.Email "" }}User Creation{{ else }}User Update{{ end }}
|
||||
<span id="modal-close" class="pointer button__round">+</span>
|
||||
</h1>
|
||||
<form method="post">
|
||||
<input type="email" name="email" value="{{ .CurrentUser.Email }}" placeholder="Email" {{ if ne .CurrentUser.Email "" }}disabled{{ end }} />
|
||||
<input type="password" name="password" value="{{ .CurrentUser.Password }}" placeholder="Password" />
|
||||
<input type="text" name="role" value="{{ .CurrentUser.Role }}" placeholder="Role" />
|
||||
<label>
|
||||
<input type="checkbox" name="disabled" {{ if eq .CurrentUser.Disabled true }}checked{{ end }}>
|
||||
Block
|
||||
</label>
|
||||
<button class="transparency">
|
||||
{{ if eq .CurrentUser.Email "" }}Create{{ else }}Update{{ end }}
|
||||
</button>
|
||||
</form>
|
||||
</dialog>
|
||||
<div class="backdrop"></div>
|
||||
|
||||
<h1>User Management <span id="user-add" class="pointer button__round">+</span></h1>
|
||||
<p class="transparency">Manage your team members and their account permissions here.</p>
|
||||
|
||||
{{ $length := len .Users }} {{ if eq $length 0 }}
|
||||
<p class="banner">
|
||||
There is not user yet, create one!
|
||||
</p>
|
||||
{{ else }}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th style="width:10px;"></th>
|
||||
<th style="width:10px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="transparency">
|
||||
{{range $user := .Users }}
|
||||
<tr class="{{ if eq $user.Disabled true }}disabled{{ end }}">
|
||||
<td>{{ $user.Email }}</td>
|
||||
<td>{{ $user.Role }}</td>
|
||||
<td class="center action" onclick="deleteItem('{{ $user.Email }}')">DEL</td>
|
||||
<td class="center action" onclick="updateItem('{{ $user.Email }}')">EDIT</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ end }}
|
||||
|
||||
{{ if ne .Referer "" }}
|
||||
<a href="{{ .Referer }}" id="back" class="transparency">< back</a>
|
||||
{{ end }}
|
||||
|
||||
<script>
|
||||
window.deleteItem = async (email) => {
|
||||
const ok = window.confirm("Are you sure you want to remove: " + email);
|
||||
if (!ok) return;
|
||||
const f = new FormData();
|
||||
f.append("email", email)
|
||||
const resp = await fetch(location.pathname, {
|
||||
method: "DELETE",
|
||||
body: f,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
alert(resp.statusText);
|
||||
return;
|
||||
}
|
||||
location.reload();
|
||||
|
||||
};
|
||||
window.updateItem = async (email) => {
|
||||
location.search = "?email=" + encodeURIComponent(email);
|
||||
};
|
||||
|
||||
(function() {
|
||||
const $dialog = document.querySelector("dialog");
|
||||
document.getElementById("user-add").onclick = () => $dialog.showModal();
|
||||
document.getElementById("modal-close").onclick = () => {
|
||||
$dialog.close();
|
||||
location.search = "";
|
||||
};
|
||||
|
||||
document.querySelector("form").onsubmit = (e) => {
|
||||
e.target.querySelector("button").setAttribute("disabled", "true");
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
package plg_authenticate_simple
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
. "github.com/mickael-kerjean/filestash/server/common"
|
||||
"github.com/mickael-kerjean/filestash/server/middleware"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
type pluginConfig struct {
|
||||
DB string `json:"db"`
|
||||
Users []User `json:"-"`
|
||||
Subject string `json:"notification_subject"`
|
||||
Body string `json:"notification_body"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
Hooks.Register.AuthenticationMiddleware("simple", SimpleAuth{})
|
||||
Hooks.Register.HttpEndpoint(func(r *mux.Router, app *App) error {
|
||||
r.Handle("/admin/simple-user-management", http.RedirectHandler("/admin/api/simple-user-management", http.StatusSeeOther)).Methods("GET")
|
||||
r.HandleFunc("/admin/api/simple-user-management", middleware.NewMiddlewareChain(
|
||||
UserManagementHandler,
|
||||
[]Middleware{middleware.AdminOnly},
|
||||
*app,
|
||||
)).Methods("GET", "POST", "DELETE", "PATCH")
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package plg_authenticate_simple
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"text/template"
|
||||
|
||||
. "github.com/mickael-kerjean/filestash/server/common"
|
||||
|
||||
"gopkg.in/gomail.v2"
|
||||
)
|
||||
|
||||
func sendInvitateMail(user User) error {
|
||||
cfg, err := getPluginData()
|
||||
if err != nil {
|
||||
return err
|
||||
} else if cfg.Subject == "" || cfg.Body == "" {
|
||||
return nil
|
||||
}
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", Config.Get("email.from").String())
|
||||
m.SetHeader("To", user.Email)
|
||||
m.SetHeader("Subject", withTemplate(cfg.Subject, user))
|
||||
m.SetBody("text/plain", withTemplate(cfg.Body, user))
|
||||
d := gomail.NewDialer(
|
||||
Config.Get("email.server").String(),
|
||||
Config.Get("email.port").Int(),
|
||||
Config.Get("email.username").String(),
|
||||
Config.Get("email.password").String(),
|
||||
)
|
||||
if err := d.DialAndSend(m); err != nil {
|
||||
return fmt.Errorf("cannot send mail - reason=%s", err.Error())
|
||||
}
|
||||
Log.Info("plg_authenticate_simple::notification action=sent email=%s", user.Email)
|
||||
return nil
|
||||
}
|
||||
|
||||
func withTemplate(in string, user User) string {
|
||||
var b bytes.Buffer
|
||||
tmpl, err := template.New("app").Parse(in)
|
||||
if err != nil {
|
||||
Log.Warning("plg_authenticate_simple::mail err=cannot_compile_template")
|
||||
return in
|
||||
}
|
||||
tmpl.Execute(&b, map[string]string{
|
||||
"user": user.Email,
|
||||
"password": user.Password,
|
||||
"role": user.Role,
|
||||
})
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package plg_authenticate_simple
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
. "github.com/mickael-kerjean/filestash/server/common"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func removeUser(email string) error {
|
||||
users, err := getUsers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range users {
|
||||
if users[i].Email == email {
|
||||
users[i] = users[len(users)-1]
|
||||
return saveUsers(users[:len(users)-1])
|
||||
}
|
||||
}
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
func createUser(user User) error {
|
||||
if user.Password == "" {
|
||||
return ErrNotValid
|
||||
}
|
||||
p, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.Password = string(p)
|
||||
users, err := getUsers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return saveUsers(append(users, user))
|
||||
}
|
||||
|
||||
func updateUser(user User) error {
|
||||
users, err := getUsers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range users {
|
||||
if users[i].Email == user.Email {
|
||||
if strings.HasPrefix(user.Password, "$2a$") == false {
|
||||
p, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.Password = string(p)
|
||||
}
|
||||
users[i] = user
|
||||
return saveUsers(users)
|
||||
}
|
||||
}
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
func getUsers() ([]User, error) {
|
||||
cfg, err := getPluginData()
|
||||
return cfg.Users, err
|
||||
}
|
||||
|
||||
func saveUsers(users []User) error {
|
||||
cfg, err := getPluginData()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Slice(users, func(i, j int) bool {
|
||||
userI := []byte(users[i].Email)
|
||||
userJ := []byte(users[j].Email)
|
||||
n := len(userI)
|
||||
if len(userJ) < len(userI) {
|
||||
n = len(userJ)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
if userI[i] == userJ[i] {
|
||||
continue
|
||||
}
|
||||
return userI[i] < userJ[i]
|
||||
}
|
||||
return false
|
||||
})
|
||||
cfg.Users = users
|
||||
return savePluginData(cfg)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package plg_authenticate_simple
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func formatRole(s string) string {
|
||||
arr := strings.Split(strings.ToLower(s), ",")
|
||||
for i := range arr {
|
||||
arr[i] = strings.TrimSpace(strings.ToLower(arr[i]))
|
||||
}
|
||||
return strings.Join(arr, ", ")
|
||||
}
|
||||
|
||||
func formatEmail(s string) string {
|
||||
return strings.TrimSpace(strings.ToLower(s))
|
||||
}
|
||||
|
||||
func formatPassword(s string) string {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
Reference in New Issue
Block a user