Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e29743d5fa | |||
| d8d7bc9570 | |||
| ff8604eac1 | |||
| d5281e7d57 |
@@ -28,6 +28,8 @@ Or with a volume, for example if you created a volume named `bibliomane_data`:
|
||||
|
||||
`--add-user` or `-a` can be used to create an account on startup. It requires a string following htpasswd format `[username]:[bcrypt hashed password]`.
|
||||
|
||||
Use `--add-admin-user` or `-A` instead to add an user as an admin.
|
||||
|
||||
The password can be generated using `htpasswd -nBC10 [username]`.
|
||||
|
||||
For example, to create an user account `demo`:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
--users
|
||||
INSERT INTO users(created_at, name, password) VALUES ('NOW', 'demo','$2a$10$7mfCBxBwIzXDU6r9az26o.zPX/r6IlNZVfU9zxSoLVtc0kRPimzba');
|
||||
INSERT INTO users(created_at, name, password) VALUES ('NOW', 'demo2','$2a$10$7mfCBxBwIzXDU6r9az26o.zPX/r6IlNZVfU9zxSoLVtc0kRPimzba');
|
||||
INSERT INTO users(created_at, name, admin, password) VALUES ('NOW', 'admin', 'true', '$2a$10$7mfCBxBwIzXDU6r9az26o.zPX/r6IlNZVfU9zxSoLVtc0kRPimzba');
|
||||
|
||||
-- cover
|
||||
INSERT INTO static_files(name, path) VALUES ('odingosochateaux.jpg', 'odingosochateaux.jpg');
|
||||
|
||||
@@ -100,6 +100,14 @@ onMounted(() => {
|
||||
<RouterLink v-if="authStore.user" to="/add" class="navbar-item" activeClass="is-active">
|
||||
{{ $t('navbar.addbook') }}
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="authStore.user && authStore.user.admin"
|
||||
to="/admin/users"
|
||||
class="navbar-item"
|
||||
activeClass="is-active"
|
||||
>
|
||||
{{ $t('navbar.usersmgt') }}
|
||||
</RouterLink>
|
||||
<div
|
||||
v-if="authStore.user && appInfo && !appInfo.demoMode"
|
||||
class="navbar-item is-hidden-desktop"
|
||||
|
||||
@@ -58,7 +58,6 @@ function onPointerMove(e) {
|
||||
if (initialGrabPosition.value == null) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
draggedPosition.value = e.pageY - initialGrabPosition.value
|
||||
emit('grabbing', draggedPosition.value)
|
||||
}
|
||||
@@ -75,13 +74,13 @@ function onPointerMove(e) {
|
||||
"
|
||||
ref="collectionitembox"
|
||||
class="collectionitembox"
|
||||
@pointermove="onPointerMove"
|
||||
@pointermove.prevent="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointerleave="clearGrabVariables"
|
||||
>
|
||||
<BookListElement v-bind="props.book">
|
||||
<template v-slot:left>
|
||||
<div class="is-hidden-desktop align-right">
|
||||
<div class="is-hidden-desktop mobile-delete">
|
||||
<div @click="$emit('delete')" class="centered closebtn clickable">
|
||||
<b-icon-x />
|
||||
</div>
|
||||
@@ -182,14 +181,16 @@ function onPointerMove(e) {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.align-right {
|
||||
.mobile-delete {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.positionwidget {
|
||||
margin-bottom: 10px;
|
||||
margin-left: 0px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { getImagePathOrDefault } from './api.js'
|
||||
import { useTemplateRef, onMounted } from 'vue'
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const emit = defineEmits(['delete'])
|
||||
|
||||
@@ -23,7 +23,7 @@ const passwordError = computed(() => {
|
||||
return extractFormErrorFromField('Password', errors.value)
|
||||
})
|
||||
|
||||
async function onSubmit(e) {
|
||||
async function onSubmit() {
|
||||
const res = await postLogin(user)
|
||||
if (res.ok) {
|
||||
let json = await res.json()
|
||||
@@ -36,7 +36,7 @@ async function onSubmit(e) {
|
||||
}
|
||||
|
||||
async function login(username, json) {
|
||||
useAuthStore().login({ username: username, token: json['token'] })
|
||||
useAuthStore().login({ username: username, admin: json['admin'], token: json['token'] })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
76
front/src/UsersManagement.vue
Normal file
76
front/src/UsersManagement.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { getUsers } from './api.js'
|
||||
|
||||
const limit = 50
|
||||
const pageNumber = ref(1)
|
||||
|
||||
const offset = computed(() => (pageNumber.value - 1) * limit)
|
||||
|
||||
const data = ref(null)
|
||||
const error = ref(null)
|
||||
|
||||
let totalUsersNumber = computed(() =>
|
||||
typeof data != 'undefined' && data.value != null ? data.value['count'] : 0,
|
||||
)
|
||||
let pageTotal = computed(() => Math.ceil(totalUsersNumber.value / limit))
|
||||
|
||||
fetchData()
|
||||
|
||||
function fetchData() {
|
||||
getUsers(data, error, limit, offset.value)
|
||||
}
|
||||
|
||||
function pageChange(newPageNumber) {
|
||||
pageNumber.value = newPageNumber
|
||||
data.value = null
|
||||
fetchData()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="error">{{ $t('usersmanagement.error', { error: error.message }) }}</div>
|
||||
<div v-else-if="data">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<th>#</th>
|
||||
<th>{{ $t('usersmanagement.name') }}</th>
|
||||
<th>{{ $t('usersmanagement.admin') }}</th>
|
||||
<th>
|
||||
<abbr :title="$t('usersmanagement.addedbookshelp')">{{
|
||||
$t('usersmanagement.addedbooks')
|
||||
}}</abbr>
|
||||
</th>
|
||||
<th>
|
||||
<abbr :title="$t('usersmanagement.bookshelp')">{{ $t('usersmanagement.books') }}</abbr>
|
||||
</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in data.users" :key="user.id">
|
||||
<th class="numbercell">{{ user.id }}</th>
|
||||
<td>{{ user.name }}</td>
|
||||
<td class="boolcell"><input type="checkbox" disabled :checked="user.admin" /></td>
|
||||
<td class="numbercell">{{ user.addedbookscount }}</td>
|
||||
<td class="numbercell">{{ user.userbookscount }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<Pagination
|
||||
:pageNumber="pageNumber"
|
||||
:pageTotal="pageTotal"
|
||||
maxItemDisplayed="11"
|
||||
@pageChange="pageChange"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>{{ $t('usersmanagement.loading') }}</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.boolcell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.numbercell {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -118,6 +118,11 @@ export function getBookCall(id) {
|
||||
return userFetch('/ws/book/' + id)
|
||||
}
|
||||
|
||||
export function getUsers(data, error, limit, offset) {
|
||||
const queryParams = new URLSearchParams({ limit: limit, offset: offset })
|
||||
return useFetch(data, error, '/ws/admin/users' + '?' + queryParams.toString())
|
||||
}
|
||||
|
||||
export function postBook(book) {
|
||||
return genericPayloadCall('/ws/book', book.value, 'POST')
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"logout": "Log out",
|
||||
"signup": "Sign up",
|
||||
"search": "Search",
|
||||
"usersmgt": "Users Management",
|
||||
"login": "Log In"
|
||||
},
|
||||
"barcode": {
|
||||
@@ -100,5 +101,15 @@
|
||||
},
|
||||
"collection": {
|
||||
"error": "Error when loading collection: {error}"
|
||||
},
|
||||
"usersmanagement": {
|
||||
"error": "Error when loading users: {error}",
|
||||
"name": "Name",
|
||||
"admin": "Is Admin ?",
|
||||
"addedbooks": "Added Books",
|
||||
"addedbookshelp": "Number of books the user created or imported.",
|
||||
"books": "Books Number",
|
||||
"bookshelp": "Total number of books of the user.",
|
||||
"loading": "Loading..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"logout": "Se déconnecter",
|
||||
"signup": "S'inscrire",
|
||||
"search": "Rechercher",
|
||||
"usersmgt": "Gestion Des Utilisateurs",
|
||||
"login": "Se connecter"
|
||||
},
|
||||
"barcode": {
|
||||
@@ -100,5 +101,15 @@
|
||||
},
|
||||
"collection": {
|
||||
"error": "Erreur pendant le chargement de la liste : {error}"
|
||||
},
|
||||
"usersmanagement": {
|
||||
"error": "Erreur pendant le chargement des utilisateurs: {error}",
|
||||
"name": "Nom",
|
||||
"admin": "Administrateur ?",
|
||||
"addedbooks": "Livres Ajoutés",
|
||||
"addedbookshelp": "Nombre de livres créés ou importés par l'utilisateur.",
|
||||
"books": "Nombre De Livres",
|
||||
"bookshelp": "Le nombre total de livres de cet utilisateur.",
|
||||
"loading": "Chargement..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import ScanBook from './ScanBook.vue'
|
||||
import SearchBook from './SearchBook.vue'
|
||||
import ImportInventaire from './ImportInventaire.vue'
|
||||
import InstanceBrowser from './InstanceBrowser.vue'
|
||||
import UsersManagement from './UsersManagement.vue'
|
||||
import { useAuthStore } from './auth.store'
|
||||
|
||||
const routes = [
|
||||
@@ -30,6 +31,7 @@ const routes = [
|
||||
{ path: '/add', component: BookFormEdit },
|
||||
{ path: '/signup', component: SignUp },
|
||||
{ path: '/login', component: LogIn },
|
||||
{ path: '/admin/users', component: UsersManagement },
|
||||
]
|
||||
|
||||
export const router = createRouter({
|
||||
|
||||
@@ -28,4 +28,5 @@ func TestGetAppInfo_Ok(t *testing.T) {
|
||||
|
||||
assert.Equal(t, false, appInfo.RegistrationDisabled)
|
||||
assert.Equal(t, false, appInfo.DemoMode)
|
||||
assert.Equal(t, false, appInfo.Admin)
|
||||
}
|
||||
|
||||
41
internal/apitest/get_users_test.go
Normal file
41
internal/apitest/get_users_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package apitest
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"git.artlef.fr/bibliomane/internal/dto"
|
||||
"git.artlef.fr/bibliomane/internal/testutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetUsersHandler_OK(t *testing.T) {
|
||||
status, result := testFetchUsers(t, "15", "0")
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.Equal(t, int64(3), result.Count)
|
||||
assert.Equal(t, 3, len(result.Users))
|
||||
}
|
||||
|
||||
func TestGetUsersHandler_Limit(t *testing.T) {
|
||||
status, result := testFetchUsers(t, "2", "0")
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.Equal(t, int64(3), result.Count)
|
||||
assert.Equal(t, 2, len(result.Users))
|
||||
}
|
||||
|
||||
func TestGetUsersHandler_Forbidden(t *testing.T) {
|
||||
status, _ := testutils.TestFetchModel[dto.UsersGet](t, "/ws/admin/users", "10", "0")
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
}
|
||||
|
||||
func TestGetUsersHandler_BookCountOK(t *testing.T) {
|
||||
status, result := testFetchUsers(t, "1", "1")
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
assert.Equal(t, "demo2", result.Users[0].Name)
|
||||
assert.Equal(t, int64(1), result.Users[0].AddedBooksCount)
|
||||
assert.Equal(t, int64(3), result.Users[0].UserBooksCount)
|
||||
}
|
||||
|
||||
func testFetchUsers(t *testing.T, limit string, offset string) (int, dto.UsersGet) {
|
||||
return testutils.TestFetchAdminModel[dto.UsersGet](t, "/ws/admin/users", limit, offset)
|
||||
}
|
||||
@@ -22,7 +22,7 @@ func TestPostImportBookHandler_Ok(t *testing.T) {
|
||||
book := testGetBook(t, strconv.FormatUint(uint64(id), 10), 200)
|
||||
assert.Equal(t, "les Hauts de Hurle-Vent", book.Title)
|
||||
assert.Equal(t, "Emily Brontë", book.Author)
|
||||
assert.Equal(t, "isbn:9782253004752", book.InventaireId)
|
||||
assert.Equal(t, "inv:31cee958a93ba50697a3fec2a360f437", book.InventaireId)
|
||||
assert.Equal(t, "/static/bookcover/44abbcbdc1092212c2bae66f5165019dac1e2a7b.webp", book.CoverPath)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestPostImportBookHandler_OkAuthorKey(t *testing.T) {
|
||||
book := testGetBook(t, strconv.FormatUint(uint64(id), 10), 200)
|
||||
assert.Equal(t, "Dr Bloodmoney", book.Title)
|
||||
assert.Equal(t, "Philip K. Dick", book.Author)
|
||||
assert.Equal(t, "isbn:9782290033630", book.InventaireId)
|
||||
assert.Equal(t, "inv:ba7d376567114bd69a6c8f0a135a89d0", book.InventaireId)
|
||||
assert.Equal(t, "/static/bookcover/1d1493159d031224a42b37c4417fcbb8c76b00bd.webp", book.CoverPath)
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ func TestSearchBook_ISBNInventaire(t *testing.T) {
|
||||
Title: "Les premières enquêtes de Maigret",
|
||||
Author: "Georges Simenon",
|
||||
Description: "roman de Georges Simenon",
|
||||
InventaireID: "isbn:9782253158400",
|
||||
InventaireID: "inv:cd8af0fc32d2f721d2853d02682dfd44",
|
||||
IsInventaireEdition: true,
|
||||
Rating: 0,
|
||||
Read: false,
|
||||
|
||||
@@ -33,6 +33,7 @@ type Config struct {
|
||||
DemoMode bool `toml:"demo-mode" short:"D" default:"false" help:"Activate demo mode." comment:"Activate demo mode: anyone connecting to the instance will be logged in as a single user."`
|
||||
DemoUsername string `toml:"demo-username" default:"demo" help:"Name of the single user used for the demo." comment:"Name of the single user used for the demo."`
|
||||
AddUser UserListAsStrings `toml:"add-user" short:"a" help:"Add users on startup following htpasswd bcrypt format." comment:"Add users on startup following htpasswd bcrypt format, example: [\"demo:$2y$10$UHR2646SZo2W.Rhna7bn5eWNLXWJZ/Sa3oLd9RlxlXs57Bwp6isOS\",\"user:$2y$10$3WYUp.VDpzJRywtrxO1s/uWfUIKpTE4yh5B1d2RCef3hvczYbEWTC\"]"`
|
||||
AddAdminUser UserListAsStrings `toml:"add-admin-user" short:"A" help:"Same as add-user but the added user has admin privilege." comment:"Same as add-user but the added user has admin privilege."`
|
||||
}
|
||||
|
||||
type UserListAsStrings []string
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.artlef.fr/bibliomane/internal/appcontext"
|
||||
@@ -21,13 +20,19 @@ func CreateUser(ac appcontext.AppContext, username string, password string) erro
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return CreateUserWithHashedPassword(ac, username, string(hashedPassword))
|
||||
|
||||
user := model.User{
|
||||
Name: username,
|
||||
Password: string(hashedPassword),
|
||||
Admin: false,
|
||||
}
|
||||
return CreateUserWithHashedPassword(ac, &user)
|
||||
}
|
||||
|
||||
// only call this method with hashed password
|
||||
func CreateUserWithHashedPassword(ac appcontext.AppContext, username string, hashedPassword string) error {
|
||||
func CreateUserWithHashedPassword(ac appcontext.AppContext, userToCreate *model.User) error {
|
||||
var existingUser model.User
|
||||
err := ac.Db.Where("name = ?", username).First(&existingUser).Error
|
||||
err := ac.Db.Where("name = ?", userToCreate.Name).First(&existingUser).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
@@ -37,41 +42,72 @@ func CreateUserWithHashedPassword(ac appcontext.AppContext, username string, has
|
||||
Err: errors.New(i18nresource.GetTranslatedMessage(&ac, "UserAlreadyExists")),
|
||||
}
|
||||
}
|
||||
user := model.User{
|
||||
Name: username,
|
||||
Password: hashedPassword,
|
||||
}
|
||||
return ac.Db.Model(&model.User{}).Save(&user).Error
|
||||
return ac.Db.Model(&model.User{}).Save(&userToCreate).Error
|
||||
}
|
||||
|
||||
func CreateDefaultUsers(ac appcontext.AppContext) error {
|
||||
usersPasswordMap, err := createNormalUsersMap(ac)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = createDefaultUsersFromMap(ac, usersPasswordMap, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
adminUsersPasswordMap, err := createDefaultUsersMap(ac, ac.Config.AddAdminUser)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return createDefaultUsersFromMap(ac, adminUsersPasswordMap, true)
|
||||
}
|
||||
|
||||
func createNormalUsersMap(ac appcontext.AppContext) (map[string]string, error) {
|
||||
usersPasswordMap, err := createDefaultUsersMap(ac, ac.Config.AddUser)
|
||||
if err != nil {
|
||||
return usersPasswordMap, err
|
||||
}
|
||||
_, ok := usersPasswordMap[ac.Config.DemoUsername]
|
||||
if !ok {
|
||||
usersPasswordMap[ac.Config.DemoUsername] = ""
|
||||
}
|
||||
return usersPasswordMap, nil
|
||||
}
|
||||
|
||||
func createDefaultUsersMap(ac appcontext.AppContext, adduser []string) (map[string]string, error) {
|
||||
usersPasswordMap := make(map[string]string)
|
||||
var usernames []string
|
||||
for _, s := range ac.Config.AddUser {
|
||||
for _, s := range adduser {
|
||||
splittedString := strings.Split(s, ":")
|
||||
if len(splittedString) < 2 {
|
||||
return fmt.Errorf(i18nresource.GetTranslatedMessage(&ac, "ErrorWhenCreatingUserFromStr"), s)
|
||||
return usersPasswordMap,
|
||||
fmt.Errorf(i18nresource.GetTranslatedMessage(&ac, "ErrorWhenCreatingUserFromStr"), s)
|
||||
}
|
||||
usernames = append(usernames, splittedString[0])
|
||||
usersPasswordMap[splittedString[0]] = splittedString[1]
|
||||
}
|
||||
if !slices.Contains(usernames, ac.Config.DemoUsername) {
|
||||
usernames = append(usernames, ac.Config.DemoUsername)
|
||||
usersPasswordMap[ac.Config.DemoUsername] = ""
|
||||
return usersPasswordMap, nil
|
||||
}
|
||||
|
||||
}
|
||||
func createDefaultUsersFromMap(
|
||||
ac appcontext.AppContext,
|
||||
usersPasswordMap map[string]string,
|
||||
isAdmin bool) error {
|
||||
|
||||
var existingUsers []model.User
|
||||
err := ac.Db.Where("name IN ?", usernames).Find(&existingUsers).Error
|
||||
err := ac.Db.Where("name IN ?", mapToArrayKey(usersPasswordMap)).Find(&existingUsers).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, username := range usernames {
|
||||
for username, password := range usersPasswordMap {
|
||||
if isInExistingUsers(username, existingUsers) {
|
||||
continue
|
||||
}
|
||||
err = CreateUserWithHashedPassword(ac, username, usersPasswordMap[username])
|
||||
|
||||
user := model.User{
|
||||
Name: username,
|
||||
Password: password,
|
||||
Admin: isAdmin,
|
||||
}
|
||||
err = CreateUserWithHashedPassword(ac, &user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -87,3 +123,11 @@ func isInExistingUsers(username string, existingUsers []model.User) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mapToArrayKey(m map[string]string) []string {
|
||||
var a []string
|
||||
for k := range m {
|
||||
a = append(a, k)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ type AppInfo struct {
|
||||
RegistrationDisabled bool `json:"registrationDisabled"`
|
||||
DemoMode bool `json:"demoMode"`
|
||||
DemoUsername string `json:"demoUsername"`
|
||||
Admin bool `json:"admin"`
|
||||
}
|
||||
|
||||
type FullBookGet struct {
|
||||
@@ -72,3 +73,16 @@ type CollectionListBookItemGet struct {
|
||||
Title string `json:"title"`
|
||||
CoverPath string `json:"coverPath"`
|
||||
}
|
||||
|
||||
type UsersGet struct {
|
||||
Count int64 `json:"count"`
|
||||
Users []UserGet `json:"users"`
|
||||
}
|
||||
|
||||
type UserGet struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Admin bool `json:"admin"`
|
||||
AddedBooksCount int64 `json:"addedbookscount"`
|
||||
UserBooksCount int64 `json:"userbookscount"`
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package jwtauth
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func GenerateJwtToken(username string) (string, error) {
|
||||
func GenerateJwtToken(username string, admin bool) (string, error) {
|
||||
var s string
|
||||
key, err := GetJwtKey()
|
||||
if err != nil {
|
||||
@@ -12,8 +14,9 @@ func GenerateJwtToken(username string) (string, error) {
|
||||
}
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodHS256,
|
||||
jwt.MapClaims{
|
||||
"iss": "bibliomane",
|
||||
"sub": username,
|
||||
"iss": "bibliomane",
|
||||
"sub": username,
|
||||
"admin": strconv.FormatBool(admin),
|
||||
})
|
||||
return t.SignedString(key)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.artlef.fr/bibliomane/internal/jwtauth"
|
||||
@@ -27,29 +28,33 @@ func Auth() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
username, err := parseUserFromJwt(c)
|
||||
jwtokenStr := jwtFromBearerToken(c.GetHeader("Authorization"))
|
||||
jwtoken, err := jwt.Parse(jwtokenStr,
|
||||
func(token *jwt.Token) (any, error) {
|
||||
return jwtauth.GetJwtKey()
|
||||
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized,
|
||||
gin.H{"error": "You must be logged in to access this resource."})
|
||||
abortError(c)
|
||||
return
|
||||
}
|
||||
|
||||
//check admin rights
|
||||
if strings.HasPrefix(c.FullPath(), "/ws/admin/") && !hasAdminRights(jwtoken) {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden,
|
||||
gin.H{"error": "You do not have the right to access this resource."})
|
||||
return
|
||||
}
|
||||
|
||||
username, err := jwtoken.Claims.GetSubject()
|
||||
if err != nil {
|
||||
abortError(c)
|
||||
} else {
|
||||
c.Set("user", username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseUserFromJwt(c *gin.Context) (string, error) {
|
||||
|
||||
jwtokenStr := jwtFromBearerToken(c.GetHeader("Authorization"))
|
||||
jwtoken, parseErr := jwt.Parse(jwtokenStr,
|
||||
func(token *jwt.Token) (any, error) {
|
||||
return jwtauth.GetJwtKey()
|
||||
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
|
||||
if parseErr != nil {
|
||||
return "", parseErr
|
||||
}
|
||||
return jwtoken.Claims.GetSubject()
|
||||
}
|
||||
|
||||
func jwtFromBearerToken(bearerToken string) string {
|
||||
splitToken := strings.Split(bearerToken, " ")
|
||||
if len(splitToken) == 2 {
|
||||
@@ -58,3 +63,28 @@ func jwtFromBearerToken(bearerToken string) string {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func hasAdminRights(token *jwt.Token) bool {
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
raw, ok := claims["admin"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
adminStr, ok := raw.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
isAdmin, err := strconv.ParseBool(adminStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return isAdmin
|
||||
}
|
||||
|
||||
func abortError(c *gin.Context) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized,
|
||||
gin.H{"error": "You must be logged in to access this resource."})
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import "gorm.io/gorm"
|
||||
|
||||
type User struct {
|
||||
gorm.Model
|
||||
Name string `gorm:"index;uniqueIndex"`
|
||||
Password string
|
||||
UserBooks []UserBook
|
||||
Name string `gorm:"index;uniqueIndex"`
|
||||
Password string
|
||||
Admin bool
|
||||
}
|
||||
|
||||
33
internal/query/queryusers.go
Normal file
33
internal/query/queryusers.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"git.artlef.fr/bibliomane/internal/dto"
|
||||
"git.artlef.fr/bibliomane/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func FetchAllUsers(db *gorm.DB, limit int, offset int) ([]dto.UserGet, error) {
|
||||
var books []dto.UserGet
|
||||
query := fetchAllUserQuery(db)
|
||||
query = query.Limit(limit)
|
||||
query = query.Offset(offset)
|
||||
query = query.Order("users.id DESC")
|
||||
res := query.Find(&books)
|
||||
return books, res.Error
|
||||
}
|
||||
|
||||
func FetchAllUsersCount(db *gorm.DB) (int64, error) {
|
||||
var count int64
|
||||
query := fetchAllUserQuery(db)
|
||||
res := query.Count(&count)
|
||||
return count, res.Error
|
||||
}
|
||||
|
||||
func fetchAllUserQuery(db *gorm.DB) *gorm.DB {
|
||||
query := db.Model(&model.User{})
|
||||
query = query.Select("users.id, users.name, users.admin, count(distinct books.id) as added_books_count, count(distinct user_books.id) as user_books_count")
|
||||
query = query.Joins("left join books on (books.added_by_id = users.id)")
|
||||
query = query.Joins("left join user_books on user_books.user_id = users.id")
|
||||
query = query.Group("users.id, users.name")
|
||||
return query
|
||||
}
|
||||
@@ -5,12 +5,24 @@ import (
|
||||
|
||||
"git.artlef.fr/bibliomane/internal/appcontext"
|
||||
"git.artlef.fr/bibliomane/internal/dto"
|
||||
"git.artlef.fr/bibliomane/internal/myvalidator"
|
||||
)
|
||||
|
||||
func GetAppInfo(ac appcontext.AppContext) {
|
||||
admin := false
|
||||
_, userIsInContext := ac.C.Get("user")
|
||||
if userIsInContext {
|
||||
user, err := ac.GetAuthenticatedUser()
|
||||
if err != nil {
|
||||
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
|
||||
return
|
||||
}
|
||||
admin = user.Admin
|
||||
}
|
||||
ac.C.JSON(http.StatusOK, dto.AppInfo{
|
||||
RegistrationDisabled: ac.Config.DisableRegistration,
|
||||
DemoMode: ac.Config.DemoMode,
|
||||
DemoUsername: ac.Config.DemoUsername,
|
||||
Admin: admin,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,12 +12,12 @@ import (
|
||||
"git.artlef.fr/bibliomane/internal/myvalidator"
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func PostLoginHandler(ac appcontext.AppContext) {
|
||||
|
||||
var username string
|
||||
admin := false
|
||||
|
||||
if !ac.Config.DemoMode {
|
||||
var user dto.UserLogin
|
||||
@@ -26,30 +26,27 @@ func PostLoginHandler(ac appcontext.AppContext) {
|
||||
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
|
||||
return
|
||||
}
|
||||
var userDb model.User
|
||||
ac.Db.Where("name = ?", user.Username).First(&userDb)
|
||||
|
||||
if !ac.Config.DemoMode && !isUserAndPasswordOk(ac.Db, user.Username, user.Password) {
|
||||
if !ac.Config.DemoMode &&
|
||||
bcrypt.CompareHashAndPassword([]byte(userDb.Password), []byte(user.Password)) != nil {
|
||||
ac.C.JSON(http.StatusUnauthorized,
|
||||
gin.H{"error": i18nresource.GetTranslatedMessage(&ac, "InvalidCredentials")})
|
||||
return
|
||||
}
|
||||
username = user.Username
|
||||
admin = userDb.Admin
|
||||
} else {
|
||||
username = ac.Config.DemoUsername
|
||||
}
|
||||
|
||||
var jwtToken string
|
||||
jwtToken, err := jwtauth.GenerateJwtToken(username)
|
||||
jwtToken, err := jwtauth.GenerateJwtToken(username, admin)
|
||||
if err != nil {
|
||||
ac.C.JSON(http.StatusUnauthorized,
|
||||
gin.H{"error": fmt.Errorf("Error when generating JWT token: %w", err)})
|
||||
return
|
||||
}
|
||||
ac.C.JSON(http.StatusOK, gin.H{"message": i18nresource.GetTranslatedMessage(&ac, "AuthenticationSuccess"), "token": jwtToken})
|
||||
}
|
||||
|
||||
func isUserAndPasswordOk(db *gorm.DB, username string, password string) bool {
|
||||
var user model.User
|
||||
db.Where("name = ?", username).First(&user)
|
||||
err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
|
||||
return err == nil
|
||||
ac.C.JSON(http.StatusOK, gin.H{"message": i18nresource.GetTranslatedMessage(&ac, "AuthenticationSuccess"), "admin": admin, "token": jwtToken})
|
||||
}
|
||||
|
||||
34
internal/routes/usersget.go
Normal file
34
internal/routes/usersget.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.artlef.fr/bibliomane/internal/appcontext"
|
||||
"git.artlef.fr/bibliomane/internal/dto"
|
||||
"git.artlef.fr/bibliomane/internal/myvalidator"
|
||||
"git.artlef.fr/bibliomane/internal/query"
|
||||
)
|
||||
|
||||
func GetUsersHandler(ac appcontext.AppContext) {
|
||||
limit, err := ac.GetQueryLimit()
|
||||
if err != nil {
|
||||
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
|
||||
return
|
||||
}
|
||||
offset, err := ac.GetQueryOffset()
|
||||
if err != nil {
|
||||
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
|
||||
return
|
||||
}
|
||||
users, err := query.FetchAllUsers(ac.Db, limit, offset)
|
||||
if err != nil {
|
||||
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
|
||||
return
|
||||
}
|
||||
count, err := query.FetchAllUsersCount(ac.Db)
|
||||
if err != nil {
|
||||
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
|
||||
return
|
||||
}
|
||||
ac.C.JSON(http.StatusOK, dto.UsersGet{Count: count, Users: users})
|
||||
}
|
||||
@@ -108,6 +108,10 @@ func Setup(config *config.Config) *gin.Engine {
|
||||
ws.DELETE("/collection/item/:id", func(c *gin.Context) {
|
||||
routes.DeleteCollectionBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
|
||||
})
|
||||
ws.GET("/admin/users", func(c *gin.Context) {
|
||||
routes.GetUsersHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
|
||||
})
|
||||
|
||||
r.Static("/static/bookcover", config.ImageFolderPath)
|
||||
|
||||
folders := []string{"assets", "css", "image"}
|
||||
|
||||
@@ -44,6 +44,15 @@ func ConnectDemo2User(router *gin.Engine) string {
|
||||
return connectUser(router, loginJson)
|
||||
}
|
||||
|
||||
func ConnectAdminUser(router *gin.Engine) string {
|
||||
loginJson :=
|
||||
`{
|
||||
"username": "admin",
|
||||
"password":"demopw"
|
||||
}`
|
||||
return connectUser(router, loginJson)
|
||||
}
|
||||
|
||||
func connectUser(router *gin.Engine, loginJson string) string {
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/ws/auth/login", strings.NewReader(loginJson))
|
||||
@@ -87,6 +96,18 @@ func TestFetchOneModel[T any](t *testing.T, urlpath string, id string) (int, T)
|
||||
|
||||
func TestFetchModel[T any](t *testing.T, urlpath string, limit string, offset string) (int, T) {
|
||||
router := TestSetup()
|
||||
token := ConnectDemoUser(router)
|
||||
return testFetchModelWithUser[T](t, urlpath, limit, offset, token)
|
||||
}
|
||||
|
||||
func TestFetchAdminModel[T any](t *testing.T, urlpath string, limit string, offset string) (int, T) {
|
||||
router := TestSetup()
|
||||
token := ConnectAdminUser(router)
|
||||
return testFetchModelWithUser[T](t, urlpath, limit, offset, token)
|
||||
}
|
||||
|
||||
func testFetchModelWithUser[T any](t *testing.T, urlpath string, limit string, offset string, token string) (int, T) {
|
||||
router := TestSetup()
|
||||
|
||||
u, err := url.Parse(urlpath)
|
||||
if err != nil {
|
||||
@@ -107,7 +128,6 @@ func TestFetchModel[T any](t *testing.T, urlpath string, limit string, offset st
|
||||
q.Set("lang", "fr")
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
token := ConnectDemoUser(router)
|
||||
req, _ := http.NewRequest("GET", u.String(), nil)
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
Reference in New Issue
Block a user