mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-07 11:19:38 +03:00
Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01cdbdb7ae | ||
|
|
fc8bbf3eab | ||
|
|
a17be959d8 | ||
|
|
ce3f489188 | ||
|
|
f4201e5740 | ||
|
|
bfbccbede1 | ||
|
|
4360da03d4 | ||
|
|
c7fea8fe08 | ||
|
|
43830a372f | ||
|
|
ae155d6745 | ||
|
|
5c834f24a6 | ||
|
|
85dc8d9791 | ||
|
|
5fd10e695a | ||
|
|
3cdab19319 | ||
|
|
5661d20e87 | ||
|
|
e7bec79f25 | ||
|
|
4f55fe2f8e | ||
|
|
91f80123e8 | ||
|
|
7a0636d0f8 | ||
|
|
3166541002 | ||
|
|
b31fbf5ba8 | ||
|
|
624d55a773 | ||
|
|
42f0ba1875 | ||
|
|
0d312e5348 | ||
|
|
7b244ea012 | ||
|
|
538b5ef4eb | ||
|
|
64937ab826 | ||
|
|
0fe5bdfbac | ||
|
|
f88687e977 | ||
|
|
a5401eb00a | ||
|
|
fa466139f0 | ||
|
|
a75cfd1f25 | ||
|
|
9c2b8057ab | ||
|
|
31ba972cfc | ||
|
|
f73b82ee57 | ||
|
|
98072ba4a9 | ||
|
|
0b15e2bf1c | ||
|
|
2e9ac21b38 | ||
|
|
129f3286d9 | ||
|
|
fe07cdaa06 | ||
|
|
cdef1b3ab0 | ||
|
|
859934d6a3 | ||
|
|
7bbcaa7cbc | ||
|
|
7e28c76e6f | ||
|
|
60d4c5902b | ||
|
|
2409d1850f | ||
|
|
c699f176bc | ||
|
|
72ad87b123 | ||
|
|
5d6d7ef5a7 | ||
|
|
7ad98fc3c3 | ||
|
|
0d6f1638fe | ||
|
|
5a4b366e56 | ||
|
|
32f6ea946f | ||
|
|
1a8a6c609a | ||
|
|
cb45c53029 | ||
|
|
6e325de226 | ||
|
|
263384cf99 | ||
|
|
68d437d05b | ||
|
|
1e56aaea04 | ||
|
|
5ba964b677 | ||
|
|
5647a8a091 | ||
|
|
f3c147d33b | ||
|
|
747f81d5d8 | ||
|
|
c9c0e5e16f | ||
|
|
d21b60079c | ||
|
|
ffa4377e65 | ||
|
|
9b8bb49a33 | ||
|
|
855409bc4f | ||
|
|
a5d72aa458 | ||
|
|
c167f40af3 | ||
|
|
06a0d829c8 | ||
|
|
790723dfc5 | ||
|
|
f3d54e4a2d | ||
|
|
6b182a435a | ||
|
|
8c01c55684 | ||
|
|
69301f7575 | ||
|
|
8ce696dff6 | ||
|
|
b043257d9a | ||
|
|
ca764caf2d | ||
|
|
41438adbd1 | ||
|
|
2ec0aa85ca | ||
|
|
193d7fb3fe | ||
|
|
07408ec112 | ||
|
|
234dd26d22 | ||
|
|
75749ef336 | ||
|
|
69a47319d5 | ||
|
|
35c48b9416 | ||
|
|
f2d320825a | ||
|
|
23402ae812 | ||
|
|
6feaf25c90 | ||
|
|
46388a591b | ||
|
|
75b4a05200 | ||
|
|
13d0260cc9 | ||
|
|
97cde9c56a | ||
|
|
5df7db5105 | ||
|
|
10c890947f | ||
|
|
25144a13c7 | ||
|
|
07a6d7655f |
@@ -232,6 +232,8 @@ SAML2_ONELOGIN_OVERRIDES=null
|
||||
SAML2_DUMP_USER_DETAILS=false
|
||||
SAML2_AUTOLOAD_METADATA=false
|
||||
SAML2_IDP_AUTHNCONTEXT=true
|
||||
SAML2_SP_x509=null
|
||||
SAML2_SP_x509_KEY=null
|
||||
|
||||
# SAML group sync configuration
|
||||
# Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/
|
||||
@@ -239,6 +241,18 @@ SAML2_USER_TO_GROUPS=false
|
||||
SAML2_GROUP_ATTRIBUTE=group
|
||||
SAML2_REMOVE_FROM_GROUPS=false
|
||||
|
||||
# OpenID Connect authentication configuration
|
||||
OIDC_NAME=SSO
|
||||
OIDC_DISPLAY_NAME_CLAIMS=name
|
||||
OIDC_CLIENT_ID=null
|
||||
OIDC_CLIENT_SECRET=null
|
||||
OIDC_ISSUER=null
|
||||
OIDC_ISSUER_DISCOVER=false
|
||||
OIDC_PUBLIC_KEY=null
|
||||
OIDC_AUTH_ENDPOINT=null
|
||||
OIDC_TOKEN_ENDPOINT=null
|
||||
OIDC_DUMP_USER_DETAILS=false
|
||||
|
||||
# Disable default third-party services such as Gravatar and Draw.IO
|
||||
# Service-specific options will override this option
|
||||
DISABLE_EXTERNAL_SERVICES=false
|
||||
|
||||
17
.github/ISSUE_TEMPLATE/api_request.md
vendored
17
.github/ISSUE_TEMPLATE/api_request.md
vendored
@@ -1,17 +0,0 @@
|
||||
---
|
||||
name: New API Endpoint or Feature
|
||||
about: Request a new endpoint or API feature be added
|
||||
labels: ":nut_and_bolt: API Request"
|
||||
---
|
||||
|
||||
#### API Endpoint or Feature
|
||||
|
||||
Clearly describe what you'd like to have added to the API.
|
||||
|
||||
#### Use-Case
|
||||
|
||||
Explain the use-case that you're working-on that requires the above request.
|
||||
|
||||
#### Additional Context
|
||||
|
||||
If required, add any other context about the feature request here.
|
||||
26
.github/ISSUE_TEMPLATE/api_request.yml
vendored
Normal file
26
.github/ISSUE_TEMPLATE/api_request.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: New API Endpoint or API Ability
|
||||
description: Request a new endpoint or API feature be added
|
||||
title: "[API Request]: "
|
||||
labels: [":nut_and_bolt: API Request"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: feature
|
||||
attributes:
|
||||
label: API Endpoint or Feature
|
||||
description: Clearly describe what you'd like to have added to the API.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: usecase
|
||||
attributes:
|
||||
label: Use-Case
|
||||
description: Explain the use-case that you're working-on that requires the above request.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context about the feature request here.
|
||||
validations:
|
||||
required: false
|
||||
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,29 +0,0 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Create a report to help us improve
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**Steps To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Your Configuration (please complete the following information):**
|
||||
- Exact BookStack Version (Found in settings):
|
||||
- PHP Version:
|
||||
- Hosting Method (Nginx/Apache/Docker):
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
62
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
62
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
name: Bug Report
|
||||
description: Create a report to help us improve or fix things
|
||||
title: "[Bug Report]: "
|
||||
labels: [":bug: Bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Describe the Bug
|
||||
description: Provide a clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Detail the steps that would replicate this issue
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behaviour
|
||||
description: Provide clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Screenshots or Additional Context
|
||||
description: Provide any additional context and screenshots here to help us solve this issue
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: bsversion
|
||||
attributes:
|
||||
label: Exact BookStack Version
|
||||
description: This can be found in the settings view of BookStack. Please provide an exact version.
|
||||
placeholder: (eg. v21.08.5)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: phpversion
|
||||
attributes:
|
||||
label: PHP Version
|
||||
description: Keep in mind your command-line PHP version may differ to that of your webserver. Provide that relevant to the issue.
|
||||
placeholder: (eg. 7.4)
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: hosting
|
||||
attributes:
|
||||
label: Hosting Environment
|
||||
description: Describe your hosting environment as much as possible including any proxies used (If applicable).
|
||||
placeholder: (eg. Ubuntu 20.04 VPS, installed using official installation script)
|
||||
validations:
|
||||
required: true
|
||||
14
.github/ISSUE_TEMPLATE/feature_request.md
vendored
14
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,14 +0,0 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest an idea for this project
|
||||
|
||||
---
|
||||
|
||||
**Describe the feature you'd like**
|
||||
A clear description of the feature you'd like implemented in BookStack.
|
||||
|
||||
**Describe the benefits this feature would bring to BookStack users**
|
||||
Explain the measurable benefits this feature would achieve.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
26
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
26
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: Feature Request
|
||||
description: Request a new language to be added to CrowdIn for you to translate
|
||||
title: "[Feature Request]: "
|
||||
labels: [":hammer: Feature Request"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Describe the feature you'd like
|
||||
description: Provide a clear description of the feature you'd like implemented in BookStack
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: benefits
|
||||
attributes:
|
||||
label: Describe the benefits this feature would bring to BookStack users
|
||||
description: Explain the measurable benefits this feature would achieve for existing BookStack users
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
validations:
|
||||
required: false
|
||||
13
.github/ISSUE_TEMPLATE/language_request.md
vendored
13
.github/ISSUE_TEMPLATE/language_request.md
vendored
@@ -1,13 +0,0 @@
|
||||
---
|
||||
name: Language Request
|
||||
about: Request a new language to be added to Crowdin for you to translate
|
||||
|
||||
---
|
||||
|
||||
### Language To Add
|
||||
|
||||
_Specify here the language you want to add._
|
||||
|
||||
----
|
||||
|
||||
_This issue template is to request a new language be added to our [Crowdin translation management project](https://crowdin.com/project/bookstack). Please don't use this template to request a new language that you are not prepared to provide translations for._
|
||||
32
.github/ISSUE_TEMPLATE/language_request.yml
vendored
Normal file
32
.github/ISSUE_TEMPLATE/language_request.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Language Request
|
||||
description: Request a new language to be added to CrowdIn for you to translate
|
||||
title: "[Language Request]: "
|
||||
labels: [":earth_africa: Translations"]
|
||||
assignees:
|
||||
- ssddanbrown
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for offering to help start a new translation for BookStack!
|
||||
- type: input
|
||||
id: language
|
||||
attributes:
|
||||
label: Language to Add
|
||||
description: What language (and region if applicable) are you offering to help add to BookStack?
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: confirm
|
||||
attributes:
|
||||
label: Confirmation of Intent
|
||||
description: |
|
||||
This issue template is to request a new language be added to our [Crowdin translation management project](https://crowdin.com/project/bookstack).
|
||||
Please don't use this template to request a new language that you are not prepared to provide translations for.
|
||||
options:
|
||||
- label: I confirm I'm offering to help translate for this new language via CrowdIn.
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
*__Note: New languages are added at specific points of the development process so it may be a small while before the requested language is added for translation.__*
|
||||
63
.github/ISSUE_TEMPLATE/support_request.yml
vendored
Normal file
63
.github/ISSUE_TEMPLATE/support_request.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
name: Support Request
|
||||
description: Request support for a specific problem you have not been able to solve yourself
|
||||
title: "[Support Request]: "
|
||||
labels: [":dog2: Support"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: useddocs
|
||||
attributes:
|
||||
label: Attempted Debugging
|
||||
description: |
|
||||
I have read the [BookStack debugging](https://www.bookstackapp.com/docs/admin/debugging/) page and seeked resolution or more
|
||||
detail for the issue.
|
||||
options:
|
||||
- label: I have read the debugging page
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: searchissue
|
||||
attributes:
|
||||
label: Searched GitHub Issues
|
||||
description: |
|
||||
I have searched for the issue and potential resolutions within the [project's GitHub issue list](https://github.com/BookStackApp/BookStack/issues)
|
||||
options:
|
||||
- label: I have searched GitHub for the issue.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: scenario
|
||||
attributes:
|
||||
label: Describe the Scenario
|
||||
description: Detail the problem that you're having or what you need support with.
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: bsversion
|
||||
attributes:
|
||||
label: Exact BookStack Version
|
||||
description: This can be found in the settings view of BookStack. Please provide an exact version.
|
||||
placeholder: (eg. v21.08.5)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Log Content
|
||||
description: If the issue has produced an error, provide any [BookStack or server log](https://www.bookstackapp.com/docs/admin/debugging/) content below.
|
||||
placeholder: Be sure to remove any confidential details in your logs
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: phpversion
|
||||
attributes:
|
||||
label: PHP Version
|
||||
description: Keep in mind your command-line PHP version may differ to that of your webserver. Provide that most relevant to the issue.
|
||||
placeholder: (eg. 7.4)
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: hosting
|
||||
attributes:
|
||||
label: Hosting Environment
|
||||
description: Describe your hosting environment as much as possible including any proxies used (If applicable).
|
||||
placeholder: (eg. Ubuntu 20.04 VPS, installed using official installation script)
|
||||
validations:
|
||||
required: true
|
||||
32
.github/SECURITY.md
vendored
Normal file
32
.github/SECURITY.md
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Only the [latest version](https://github.com/BookStackApp/BookStack/releases) of BookStack is supported.
|
||||
We generally don't support older versions of BookStack due to maintenance effort and
|
||||
since we aim to provide a fairly stable upgrade path for new versions.
|
||||
|
||||
## Security Notifications
|
||||
|
||||
If you'd like to be notified of new potential security concerns you can [sign-up to the BookStack security mailing list](https://updates.bookstackapp.com/signup/bookstack-security-updates).
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you've found an issue that likely has no impact to existing users (For example, in a development-only branch)
|
||||
feel free to raise it via a standard GitHub bug report issue.
|
||||
|
||||
If the issue could have a security impact to BookStack instances, please use one of the below
|
||||
methods to report the vulnerability:
|
||||
|
||||
- Directly contact the lead maintainer [@ssddanbrown](https://github.com/ssddanbrown).
|
||||
- You will need to login to be able to see the email address on the [GitHub profile page](https://github.com/ssddanbrown).
|
||||
- Alternatively you can send a DM via Twitter to [@ssddanbrown](https://twitter.com/ssddanbrown).
|
||||
- [Disclose via huntr.dev](https://huntr.dev/bounties/disclose)
|
||||
- Bounties may be available to you through this platform.
|
||||
- Be sure to use `https://github.com/BookStackApp/BookStack` as the repository URL.
|
||||
|
||||
Please be patient while the vulnerability is being reviewed. Deploying the fix to address the vulnerability
|
||||
can often take a little time due to the amount of preparation required, to ensure the vulnerability has
|
||||
been covered, and to create the content required to adequately notify the user-base.
|
||||
|
||||
Thank you for keeping BookStack instances safe!
|
||||
4
.github/translators.txt
vendored
4
.github/translators.txt
vendored
@@ -192,3 +192,7 @@ Atalonica :: Catalan
|
||||
慕容潭谈 (591442386) :: Chinese Simplified
|
||||
Radim Pesek (ramess18) :: Czech
|
||||
anastasiia.motylko :: Ukrainian
|
||||
Indrek Haav (IndrekHaav) :: Estonian
|
||||
na3shkw :: Japanese
|
||||
Giancarlo Di Massa (digitall-it) :: Italian
|
||||
M Nafis Al Mukhdi (mnafisalmukhdi1) :: Indonesian
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2020 Dan Brown and the BookStack Project contributors
|
||||
Copyright (c) 2015-present, Dan Brown and the BookStack Project contributors
|
||||
https://github.com/BookStackApp/BookStack/graphs/contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
|
||||
@@ -66,13 +66,13 @@ class CommentRepo
|
||||
/**
|
||||
* Delete a comment from the system.
|
||||
*/
|
||||
public function delete(Comment $comment)
|
||||
public function delete(Comment $comment): void
|
||||
{
|
||||
$comment->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the given comment markdown text to HTML.
|
||||
* Convert the given comment Markdown to HTML.
|
||||
*/
|
||||
public function commentToHtml(string $commentText): string
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@ use BookStack\Auth\Role;
|
||||
use BookStack\Auth\User;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ExternalAuthService
|
||||
class GroupSyncService
|
||||
{
|
||||
/**
|
||||
* Check a role against an array of group names to see if it matches.
|
||||
@@ -60,13 +60,13 @@ class ExternalAuthService
|
||||
/**
|
||||
* Sync the groups to the user roles for the current user.
|
||||
*/
|
||||
public function syncWithGroups(User $user, array $userGroups): void
|
||||
public function syncUserWithFoundGroups(User $user, array $userGroups, bool $detachExisting): void
|
||||
{
|
||||
// Get the ids for the roles from the names
|
||||
$groupsAsRoles = $this->matchGroupsToSystemsRoles($userGroups);
|
||||
|
||||
// Sync groups
|
||||
if ($this->config['remove_from_groups']) {
|
||||
if ($detachExisting) {
|
||||
$user->roles()->sync($groupsAsRoles);
|
||||
$user->attachDefaultRole();
|
||||
} else {
|
||||
@@ -10,7 +10,7 @@ namespace BookStack\Auth\Access\Guards;
|
||||
* via the Saml2 controller & Saml2Service. This class provides a safer, thin
|
||||
* version of SessionGuard.
|
||||
*/
|
||||
class Saml2SessionGuard extends ExternalBaseSessionGuard
|
||||
class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard
|
||||
{
|
||||
/**
|
||||
* Validate a user's credentials.
|
||||
@@ -13,9 +13,10 @@ use Illuminate\Support\Facades\Log;
|
||||
* Class LdapService
|
||||
* Handles any app-specific LDAP tasks.
|
||||
*/
|
||||
class LdapService extends ExternalAuthService
|
||||
class LdapService
|
||||
{
|
||||
protected $ldap;
|
||||
protected $groupSyncService;
|
||||
protected $ldapConnection;
|
||||
protected $userAvatars;
|
||||
protected $config;
|
||||
@@ -24,20 +25,19 @@ class LdapService extends ExternalAuthService
|
||||
/**
|
||||
* LdapService constructor.
|
||||
*/
|
||||
public function __construct(Ldap $ldap, UserAvatars $userAvatars)
|
||||
public function __construct(Ldap $ldap, UserAvatars $userAvatars, GroupSyncService $groupSyncService)
|
||||
{
|
||||
$this->ldap = $ldap;
|
||||
$this->userAvatars = $userAvatars;
|
||||
$this->groupSyncService = $groupSyncService;
|
||||
$this->config = config('services.ldap');
|
||||
$this->enabled = config('auth.method') === 'ldap';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if groups should be synced.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function shouldSyncGroups()
|
||||
public function shouldSyncGroups(): bool
|
||||
{
|
||||
return $this->enabled && $this->config['user_to_groups'] !== false;
|
||||
}
|
||||
@@ -285,9 +285,8 @@ class LdapService extends ExternalAuthService
|
||||
}
|
||||
|
||||
$userGroups = $this->groupFilter($user);
|
||||
$userGroups = $this->getGroupsRecursive($userGroups, []);
|
||||
|
||||
return $userGroups;
|
||||
return $this->getGroupsRecursive($userGroups, []);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -374,7 +373,7 @@ class LdapService extends ExternalAuthService
|
||||
public function syncGroups(User $user, string $username)
|
||||
{
|
||||
$userLdapGroups = $this->getUserGroups($username);
|
||||
$this->syncWithGroups($user, $userLdapGroups);
|
||||
$this->groupSyncService->syncUserWithFoundGroups($user, $userLdapGroups, $this->config['remove_from_groups']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,7 +47,7 @@ class LoginService
|
||||
|
||||
// Authenticate on all session guards if a likely admin
|
||||
if ($user->can('users-manage') && $user->can('user-roles-manage')) {
|
||||
$guards = ['standard', 'ldap', 'saml2'];
|
||||
$guards = ['standard', 'ldap', 'saml2', 'oidc'];
|
||||
foreach ($guards as $guard) {
|
||||
auth($guard)->login($user);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use BaconQrCode\Renderer\ImageRenderer;
|
||||
use BaconQrCode\Renderer\RendererStyle\Fill;
|
||||
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
|
||||
use BaconQrCode\Writer;
|
||||
use BookStack\Auth\User;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
use PragmaRX\Google2FA\Support\Constants;
|
||||
|
||||
@@ -36,11 +37,11 @@ class TotpService
|
||||
/**
|
||||
* Generate a TOTP URL from secret key.
|
||||
*/
|
||||
public function generateUrl(string $secret): string
|
||||
public function generateUrl(string $secret, User $user): string
|
||||
{
|
||||
return $this->google2fa->getQRCodeUrl(
|
||||
setting('app-name'),
|
||||
user()->email,
|
||||
$user->email,
|
||||
$secret
|
||||
);
|
||||
}
|
||||
|
||||
53
app/Auth/Access/Oidc/OidcAccessToken.php
Normal file
53
app/Auth/Access/Oidc/OidcAccessToken.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access\Oidc;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use League\OAuth2\Client\Token\AccessToken;
|
||||
|
||||
class OidcAccessToken extends AccessToken
|
||||
{
|
||||
/**
|
||||
* Constructs an access token.
|
||||
*
|
||||
* @param array $options An array of options returned by the service provider
|
||||
* in the access token request. The `access_token` option is required.
|
||||
*
|
||||
* @throws InvalidArgumentException if `access_token` is not provided in `$options`.
|
||||
*/
|
||||
public function __construct(array $options = [])
|
||||
{
|
||||
parent::__construct($options);
|
||||
$this->validate($options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate this access token response for OIDC.
|
||||
* As per https://openid.net/specs/openid-connect-basic-1_0.html#TokenOK.
|
||||
*/
|
||||
private function validate(array $options): void
|
||||
{
|
||||
// access_token: REQUIRED. Access Token for the UserInfo Endpoint.
|
||||
// Performed on the extended class
|
||||
|
||||
// token_type: REQUIRED. OAuth 2.0 Token Type value. The value MUST be Bearer, as specified in OAuth 2.0
|
||||
// Bearer Token Usage [RFC6750], for Clients using this subset.
|
||||
// Note that the token_type value is case-insensitive.
|
||||
if (strtolower(($options['token_type'] ?? '')) !== 'bearer') {
|
||||
throw new InvalidArgumentException('The response token type MUST be "Bearer"');
|
||||
}
|
||||
|
||||
// id_token: REQUIRED. ID Token.
|
||||
if (empty($options['id_token'])) {
|
||||
throw new InvalidArgumentException('An "id_token" property must be provided');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the id token value from this access token response.
|
||||
*/
|
||||
public function getIdToken(): string
|
||||
{
|
||||
return $this->getValues()['id_token'];
|
||||
}
|
||||
}
|
||||
238
app/Auth/Access/Oidc/OidcIdToken.php
Normal file
238
app/Auth/Access/Oidc/OidcIdToken.php
Normal file
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access\Oidc;
|
||||
|
||||
class OidcIdToken
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $header;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $payload;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature;
|
||||
|
||||
/**
|
||||
* @var array[]|string[]
|
||||
*/
|
||||
protected $keys;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $issuer;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $tokenParts = [];
|
||||
|
||||
public function __construct(string $token, string $issuer, array $keys)
|
||||
{
|
||||
$this->keys = $keys;
|
||||
$this->issuer = $issuer;
|
||||
$this->parse($token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the token content into its components.
|
||||
*/
|
||||
protected function parse(string $token): void
|
||||
{
|
||||
$this->tokenParts = explode('.', $token);
|
||||
$this->header = $this->parseEncodedTokenPart($this->tokenParts[0]);
|
||||
$this->payload = $this->parseEncodedTokenPart($this->tokenParts[1] ?? '');
|
||||
$this->signature = $this->base64UrlDecode($this->tokenParts[2] ?? '') ?: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Base64-JSON encoded token part.
|
||||
* Returns the data as a key-value array or empty array upon error.
|
||||
*/
|
||||
protected function parseEncodedTokenPart(string $part): array
|
||||
{
|
||||
$json = $this->base64UrlDecode($part) ?: '{}';
|
||||
$decoded = json_decode($json, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64URL decode. Needs some character conversions to be compatible
|
||||
* with PHP's default base64 handling.
|
||||
*/
|
||||
protected function base64UrlDecode(string $encoded): string
|
||||
{
|
||||
return base64_decode(strtr($encoded, '-_', '+/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all possible parts of the id token.
|
||||
*
|
||||
* @throws OidcInvalidTokenException
|
||||
*/
|
||||
public function validate(string $clientId): bool
|
||||
{
|
||||
$this->validateTokenStructure();
|
||||
$this->validateTokenSignature();
|
||||
$this->validateTokenClaims($clientId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a specific claim from this token.
|
||||
* Returns null if it is null or does not exist.
|
||||
*
|
||||
* @return mixed|null
|
||||
*/
|
||||
public function getClaim(string $claim)
|
||||
{
|
||||
return $this->payload[$claim] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all returned claims within the token.
|
||||
*/
|
||||
public function getAllClaims(): array
|
||||
{
|
||||
return $this->payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the structure of the given token and ensure we have the required pieces.
|
||||
* As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2.
|
||||
*
|
||||
* @throws OidcInvalidTokenException
|
||||
*/
|
||||
protected function validateTokenStructure(): void
|
||||
{
|
||||
foreach (['header', 'payload'] as $prop) {
|
||||
if (empty($this->$prop) || !is_array($this->$prop)) {
|
||||
throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token");
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($this->signature) || !is_string($this->signature)) {
|
||||
throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the signature of the given token and ensure it validates against the provided key.
|
||||
*
|
||||
* @throws OidcInvalidTokenException
|
||||
*/
|
||||
protected function validateTokenSignature(): void
|
||||
{
|
||||
if ($this->header['alg'] !== 'RS256') {
|
||||
throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}");
|
||||
}
|
||||
|
||||
$parsedKeys = array_map(function ($key) {
|
||||
try {
|
||||
return new OidcJwtSigningKey($key);
|
||||
} catch (OidcInvalidKeyException $e) {
|
||||
throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage());
|
||||
}
|
||||
}, $this->keys);
|
||||
|
||||
$parsedKeys = array_filter($parsedKeys);
|
||||
|
||||
$contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1];
|
||||
/** @var OidcJwtSigningKey $parsedKey */
|
||||
foreach ($parsedKeys as $parsedKey) {
|
||||
if ($parsedKey->verify($contentToSign, $this->signature)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the claims of the token.
|
||||
* As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation.
|
||||
*
|
||||
* @throws OidcInvalidTokenException
|
||||
*/
|
||||
protected function validateTokenClaims(string $clientId): void
|
||||
{
|
||||
// 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
|
||||
// MUST exactly match the value of the iss (issuer) Claim.
|
||||
if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) {
|
||||
throw new OidcInvalidTokenException('Missing or non-matching token issuer value');
|
||||
}
|
||||
|
||||
// 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered
|
||||
// at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected
|
||||
// if the ID Token does not list the Client as a valid audience, or if it contains additional
|
||||
// audiences not trusted by the Client.
|
||||
if (empty($this->payload['aud'])) {
|
||||
throw new OidcInvalidTokenException('Missing token audience value');
|
||||
}
|
||||
|
||||
$aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
|
||||
if (count($aud) !== 1) {
|
||||
throw new OidcInvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1');
|
||||
}
|
||||
|
||||
if ($aud[0] !== $clientId) {
|
||||
throw new OidcInvalidTokenException('Token audience value did not match the expected client_id');
|
||||
}
|
||||
|
||||
// 3. If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present.
|
||||
// NOTE: Addressed by enforcing a count of 1 above.
|
||||
|
||||
// 4. If an azp (authorized party) Claim is present, the Client SHOULD verify that its client_id
|
||||
// is the Claim Value.
|
||||
if (isset($this->payload['azp']) && $this->payload['azp'] !== $clientId) {
|
||||
throw new OidcInvalidTokenException('Token authorized party exists but does not match the expected client_id');
|
||||
}
|
||||
|
||||
// 5. The current time MUST be before the time represented by the exp Claim
|
||||
// (possibly allowing for some small leeway to account for clock skew).
|
||||
if (empty($this->payload['exp'])) {
|
||||
throw new OidcInvalidTokenException('Missing token expiration time value');
|
||||
}
|
||||
|
||||
$skewSeconds = 120;
|
||||
$now = time();
|
||||
if ($now >= (intval($this->payload['exp']) + $skewSeconds)) {
|
||||
throw new OidcInvalidTokenException('Token has expired');
|
||||
}
|
||||
|
||||
// 6. The iat Claim can be used to reject tokens that were issued too far away from the current time,
|
||||
// limiting the amount of time that nonces need to be stored to prevent attacks.
|
||||
// The acceptable range is Client specific.
|
||||
if (empty($this->payload['iat'])) {
|
||||
throw new OidcInvalidTokenException('Missing token issued at time value');
|
||||
}
|
||||
|
||||
$dayAgo = time() - 86400;
|
||||
$iat = intval($this->payload['iat']);
|
||||
if ($iat > ($now + $skewSeconds) || $iat < $dayAgo) {
|
||||
throw new OidcInvalidTokenException('Token issue at time is not recent or is invalid');
|
||||
}
|
||||
|
||||
// 7. If the acr Claim was requested, the Client SHOULD check that the asserted Claim Value is appropriate.
|
||||
// The meaning and processing of acr Claim Values is out of scope for this document.
|
||||
// NOTE: Not used for our case here. acr is not requested.
|
||||
|
||||
// 8. When a max_age request is made, the Client SHOULD check the auth_time Claim value and request
|
||||
// re-authentication if it determines too much time has elapsed since the last End-User authentication.
|
||||
// NOTE: Not used for our case here. A max_age request is not made.
|
||||
|
||||
// Custom: Ensure the "sub" (Subject) Claim exists and has a value.
|
||||
if (empty($this->payload['sub'])) {
|
||||
throw new OidcInvalidTokenException('Missing token subject value');
|
||||
}
|
||||
}
|
||||
}
|
||||
7
app/Auth/Access/Oidc/OidcInvalidKeyException.php
Normal file
7
app/Auth/Access/Oidc/OidcInvalidKeyException.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access\Oidc;
|
||||
|
||||
class OidcInvalidKeyException extends \Exception
|
||||
{
|
||||
}
|
||||
9
app/Auth/Access/Oidc/OidcInvalidTokenException.php
Normal file
9
app/Auth/Access/Oidc/OidcInvalidTokenException.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access\Oidc;
|
||||
|
||||
use Exception;
|
||||
|
||||
class OidcInvalidTokenException extends Exception
|
||||
{
|
||||
}
|
||||
7
app/Auth/Access/Oidc/OidcIssuerDiscoveryException.php
Normal file
7
app/Auth/Access/Oidc/OidcIssuerDiscoveryException.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access\Oidc;
|
||||
|
||||
class OidcIssuerDiscoveryException extends \Exception
|
||||
{
|
||||
}
|
||||
109
app/Auth/Access/Oidc/OidcJwtSigningKey.php
Normal file
109
app/Auth/Access/Oidc/OidcJwtSigningKey.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access\Oidc;
|
||||
|
||||
use phpseclib3\Crypt\Common\PublicKey;
|
||||
use phpseclib3\Crypt\PublicKeyLoader;
|
||||
use phpseclib3\Crypt\RSA;
|
||||
use phpseclib3\Math\BigInteger;
|
||||
|
||||
class OidcJwtSigningKey
|
||||
{
|
||||
/**
|
||||
* @var PublicKey
|
||||
*/
|
||||
protected $key;
|
||||
|
||||
/**
|
||||
* Can be created either from a JWK parameter array or local file path to load a certificate from.
|
||||
* Examples:
|
||||
* 'file:///var/www/cert.pem'
|
||||
* ['kty' => 'RSA', 'alg' => 'RS256', 'n' => 'abc123...'].
|
||||
*
|
||||
* @param array|string $jwkOrKeyPath
|
||||
*
|
||||
* @throws OidcInvalidKeyException
|
||||
*/
|
||||
public function __construct($jwkOrKeyPath)
|
||||
{
|
||||
if (is_array($jwkOrKeyPath)) {
|
||||
$this->loadFromJwkArray($jwkOrKeyPath);
|
||||
} elseif (is_string($jwkOrKeyPath) && strpos($jwkOrKeyPath, 'file://') === 0) {
|
||||
$this->loadFromPath($jwkOrKeyPath);
|
||||
} else {
|
||||
throw new OidcInvalidKeyException('Unexpected type of key value provided');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws OidcInvalidKeyException
|
||||
*/
|
||||
protected function loadFromPath(string $path)
|
||||
{
|
||||
try {
|
||||
$this->key = PublicKeyLoader::load(
|
||||
file_get_contents($path)
|
||||
)->withPadding(RSA::SIGNATURE_PKCS1);
|
||||
} catch (\Exception $exception) {
|
||||
throw new OidcInvalidKeyException("Failed to load key from file path with error: {$exception->getMessage()}");
|
||||
}
|
||||
|
||||
if (!($this->key instanceof RSA)) {
|
||||
throw new OidcInvalidKeyException('Key loaded from file path is not an RSA key as expected');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws OidcInvalidKeyException
|
||||
*/
|
||||
protected function loadFromJwkArray(array $jwk)
|
||||
{
|
||||
if ($jwk['alg'] !== 'RS256') {
|
||||
throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$jwk['alg']}");
|
||||
}
|
||||
|
||||
if (empty($jwk['use'])) {
|
||||
throw new OidcInvalidKeyException('A "use" parameter on the provided key is expected');
|
||||
}
|
||||
|
||||
if ($jwk['use'] !== 'sig') {
|
||||
throw new OidcInvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['use']}");
|
||||
}
|
||||
|
||||
if (empty($jwk['e'])) {
|
||||
throw new OidcInvalidKeyException('An "e" parameter on the provided key is expected');
|
||||
}
|
||||
|
||||
if (empty($jwk['n'])) {
|
||||
throw new OidcInvalidKeyException('A "n" parameter on the provided key is expected');
|
||||
}
|
||||
|
||||
$n = strtr($jwk['n'] ?? '', '-_', '+/');
|
||||
|
||||
try {
|
||||
/** @var RSA $key */
|
||||
$this->key = PublicKeyLoader::load([
|
||||
'e' => new BigInteger(base64_decode($jwk['e']), 256),
|
||||
'n' => new BigInteger(base64_decode($n), 256),
|
||||
])->withPadding(RSA::SIGNATURE_PKCS1);
|
||||
} catch (\Exception $exception) {
|
||||
throw new OidcInvalidKeyException("Failed to load key from JWK parameters with error: {$exception->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this key to sign the given content and return the signature.
|
||||
*/
|
||||
public function verify(string $content, string $signature): bool
|
||||
{
|
||||
return $this->key->verify($content, $signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the key to a PEM encoded key string.
|
||||
*/
|
||||
public function toPem(): string
|
||||
{
|
||||
return $this->key->toString('PKCS8');
|
||||
}
|
||||
}
|
||||
127
app/Auth/Access/Oidc/OidcOAuthProvider.php
Normal file
127
app/Auth/Access/Oidc/OidcOAuthProvider.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access\Oidc;
|
||||
|
||||
use League\OAuth2\Client\Grant\AbstractGrant;
|
||||
use League\OAuth2\Client\Provider\AbstractProvider;
|
||||
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
|
||||
use League\OAuth2\Client\Provider\GenericResourceOwner;
|
||||
use League\OAuth2\Client\Provider\ResourceOwnerInterface;
|
||||
use League\OAuth2\Client\Token\AccessToken;
|
||||
use League\OAuth2\Client\Tool\BearerAuthorizationTrait;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Extended OAuth2Provider for using with OIDC.
|
||||
* Credit to the https://github.com/steverhoades/oauth2-openid-connect-client
|
||||
* project for the idea of extending a League\OAuth2 client for this use-case.
|
||||
*/
|
||||
class OidcOAuthProvider extends AbstractProvider
|
||||
{
|
||||
use BearerAuthorizationTrait;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $authorizationEndpoint;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $tokenEndpoint;
|
||||
|
||||
/**
|
||||
* Returns the base URL for authorizing a client.
|
||||
*/
|
||||
public function getBaseAuthorizationUrl(): string
|
||||
{
|
||||
return $this->authorizationEndpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the base URL for requesting an access token.
|
||||
*/
|
||||
public function getBaseAccessTokenUrl(array $params): string
|
||||
{
|
||||
return $this->tokenEndpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URL for requesting the resource owner's details.
|
||||
*/
|
||||
public function getResourceOwnerDetailsUrl(AccessToken $token): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default scopes used by this provider.
|
||||
*
|
||||
* This should only be the scopes that are required to request the details
|
||||
* of the resource owner, rather than all the available scopes.
|
||||
*/
|
||||
protected function getDefaultScopes(): array
|
||||
{
|
||||
return ['openid', 'profile', 'email'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the string that should be used to separate scopes when building
|
||||
* the URL for requesting an access token.
|
||||
*/
|
||||
protected function getScopeSeparator(): string
|
||||
{
|
||||
return ' ';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a provider response for errors.
|
||||
*
|
||||
* @param ResponseInterface $response
|
||||
* @param array|string $data Parsed response data
|
||||
*
|
||||
* @throws IdentityProviderException
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function checkResponse(ResponseInterface $response, $data)
|
||||
{
|
||||
if ($response->getStatusCode() >= 400 || isset($data['error'])) {
|
||||
throw new IdentityProviderException(
|
||||
$data['error'] ?? $response->getReasonPhrase(),
|
||||
$response->getStatusCode(),
|
||||
(string) $response->getBody()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a resource owner object from a successful resource owner
|
||||
* details request.
|
||||
*
|
||||
* @param array $response
|
||||
* @param AccessToken $token
|
||||
*
|
||||
* @return ResourceOwnerInterface
|
||||
*/
|
||||
protected function createResourceOwner(array $response, AccessToken $token)
|
||||
{
|
||||
return new GenericResourceOwner($response, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an access token from a response.
|
||||
*
|
||||
* The grant that was used to fetch the response can be used to provide
|
||||
* additional context.
|
||||
*
|
||||
* @param array $response
|
||||
* @param AbstractGrant $grant
|
||||
*
|
||||
* @return OidcAccessToken
|
||||
*/
|
||||
protected function createAccessToken(array $response, AbstractGrant $grant)
|
||||
{
|
||||
return new OidcAccessToken($response);
|
||||
}
|
||||
}
|
||||
203
app/Auth/Access/Oidc/OidcProviderSettings.php
Normal file
203
app/Auth/Access/Oidc/OidcProviderSettings.php
Normal file
@@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access\Oidc;
|
||||
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use Illuminate\Contracts\Cache\Repository;
|
||||
use InvalidArgumentException;
|
||||
use Psr\Http\Client\ClientExceptionInterface;
|
||||
use Psr\Http\Client\ClientInterface;
|
||||
|
||||
/**
|
||||
* OpenIdConnectProviderSettings
|
||||
* Acts as a DTO for settings used within the oidc request and token handling.
|
||||
* Performs auto-discovery upon request.
|
||||
*/
|
||||
class OidcProviderSettings
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $issuer;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $clientId;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $clientSecret;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $redirectUri;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $authorizationEndpoint;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $tokenEndpoint;
|
||||
|
||||
/**
|
||||
* @var string[]|array[]
|
||||
*/
|
||||
public $keys = [];
|
||||
|
||||
public function __construct(array $settings)
|
||||
{
|
||||
$this->applySettingsFromArray($settings);
|
||||
$this->validateInitial();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an array of settings to populate setting properties within this class.
|
||||
*/
|
||||
protected function applySettingsFromArray(array $settingsArray)
|
||||
{
|
||||
foreach ($settingsArray as $key => $value) {
|
||||
if (property_exists($this, $key)) {
|
||||
$this->$key = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate any core, required properties have been set.
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function validateInitial()
|
||||
{
|
||||
$required = ['clientId', 'clientSecret', 'redirectUri', 'issuer'];
|
||||
foreach ($required as $prop) {
|
||||
if (empty($this->$prop)) {
|
||||
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
|
||||
}
|
||||
}
|
||||
|
||||
if (strpos($this->issuer, 'https://') !== 0) {
|
||||
throw new InvalidArgumentException('Issuer value must start with https://');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a full validation on these settings.
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function validate(): void
|
||||
{
|
||||
$this->validateInitial();
|
||||
$required = ['keys', 'tokenEndpoint', 'authorizationEndpoint'];
|
||||
foreach ($required as $prop) {
|
||||
if (empty($this->$prop)) {
|
||||
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and autoload settings from the configured issuer.
|
||||
*
|
||||
* @throws OidcIssuerDiscoveryException
|
||||
*/
|
||||
public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes)
|
||||
{
|
||||
try {
|
||||
$cacheKey = 'oidc-discovery::' . $this->issuer;
|
||||
$discoveredSettings = $cache->remember($cacheKey, $cacheMinutes * 60, function () use ($httpClient) {
|
||||
return $this->loadSettingsFromIssuerDiscovery($httpClient);
|
||||
});
|
||||
$this->applySettingsFromArray($discoveredSettings);
|
||||
} catch (ClientExceptionInterface $exception) {
|
||||
throw new OidcIssuerDiscoveryException("HTTP request failed during discovery with error: {$exception->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws OidcIssuerDiscoveryException
|
||||
* @throws ClientExceptionInterface
|
||||
*/
|
||||
protected function loadSettingsFromIssuerDiscovery(ClientInterface $httpClient): array
|
||||
{
|
||||
$issuerUrl = rtrim($this->issuer, '/') . '/.well-known/openid-configuration';
|
||||
$request = new Request('GET', $issuerUrl);
|
||||
$response = $httpClient->sendRequest($request);
|
||||
$result = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
if (empty($result) || !is_array($result)) {
|
||||
throw new OidcIssuerDiscoveryException("Error discovering provider settings from issuer at URL {$issuerUrl}");
|
||||
}
|
||||
|
||||
if ($result['issuer'] !== $this->issuer) {
|
||||
throw new OidcIssuerDiscoveryException('Unexpected issuer value found on discovery response');
|
||||
}
|
||||
|
||||
$discoveredSettings = [];
|
||||
|
||||
if (!empty($result['authorization_endpoint'])) {
|
||||
$discoveredSettings['authorizationEndpoint'] = $result['authorization_endpoint'];
|
||||
}
|
||||
|
||||
if (!empty($result['token_endpoint'])) {
|
||||
$discoveredSettings['tokenEndpoint'] = $result['token_endpoint'];
|
||||
}
|
||||
|
||||
if (!empty($result['jwks_uri'])) {
|
||||
$keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient);
|
||||
$discoveredSettings['keys'] = $this->filterKeys($keys);
|
||||
}
|
||||
|
||||
return $discoveredSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the given JWK keys down to just those we support.
|
||||
*/
|
||||
protected function filterKeys(array $keys): array
|
||||
{
|
||||
return array_filter($keys, function (array $key) {
|
||||
return $key['kty'] === 'RSA' && $key['use'] === 'sig' && $key['alg'] === 'RS256';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an array of jwks as PHP key=>value arrays.
|
||||
*
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws OidcIssuerDiscoveryException
|
||||
*/
|
||||
protected function loadKeysFromUri(string $uri, ClientInterface $httpClient): array
|
||||
{
|
||||
$request = new Request('GET', $uri);
|
||||
$response = $httpClient->sendRequest($request);
|
||||
$result = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
if (empty($result) || !is_array($result) || !isset($result['keys'])) {
|
||||
throw new OidcIssuerDiscoveryException('Error reading keys from issuer jwks_uri');
|
||||
}
|
||||
|
||||
return $result['keys'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the settings needed by an OAuth provider, as a key=>value array.
|
||||
*/
|
||||
public function arrayForProvider(): array
|
||||
{
|
||||
$settingKeys = ['clientId', 'clientSecret', 'redirectUri', 'authorizationEndpoint', 'tokenEndpoint'];
|
||||
$settings = [];
|
||||
foreach ($settingKeys as $setting) {
|
||||
$settings[$setting] = $this->$setting;
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
}
|
||||
221
app/Auth/Access/Oidc/OidcService.php
Normal file
221
app/Auth/Access/Oidc/OidcService.php
Normal file
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access\Oidc;
|
||||
|
||||
use function auth;
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\Access\RegistrationService;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\JsonDebugException;
|
||||
use BookStack\Exceptions\OpenIdConnectException;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use function config;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
|
||||
use Psr\Http\Client\ClientExceptionInterface;
|
||||
use Psr\Http\Client\ClientInterface as HttpClient;
|
||||
use function trans;
|
||||
use function url;
|
||||
|
||||
/**
|
||||
* Class OpenIdConnectService
|
||||
* Handles any app-specific OIDC tasks.
|
||||
*/
|
||||
class OidcService
|
||||
{
|
||||
protected $registrationService;
|
||||
protected $loginService;
|
||||
protected $httpClient;
|
||||
|
||||
/**
|
||||
* OpenIdService constructor.
|
||||
*/
|
||||
public function __construct(RegistrationService $registrationService, LoginService $loginService, HttpClient $httpClient)
|
||||
{
|
||||
$this->registrationService = $registrationService;
|
||||
$this->loginService = $loginService;
|
||||
$this->httpClient = $httpClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate an authorization flow.
|
||||
*
|
||||
* @return array{url: string, state: string}
|
||||
*/
|
||||
public function login(): array
|
||||
{
|
||||
$settings = $this->getProviderSettings();
|
||||
$provider = $this->getProvider($settings);
|
||||
|
||||
return [
|
||||
'url' => $provider->getAuthorizationUrl(),
|
||||
'state' => $provider->getState(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the Authorization response from the authorization server and
|
||||
* return the matching, or new if registration active, user matched to
|
||||
* the authorization server.
|
||||
* Returns null if not authenticated.
|
||||
*
|
||||
* @throws Exception
|
||||
* @throws ClientExceptionInterface
|
||||
*/
|
||||
public function processAuthorizeResponse(?string $authorizationCode): ?User
|
||||
{
|
||||
$settings = $this->getProviderSettings();
|
||||
$provider = $this->getProvider($settings);
|
||||
|
||||
// Try to exchange authorization code for access token
|
||||
$accessToken = $provider->getAccessToken('authorization_code', [
|
||||
'code' => $authorizationCode,
|
||||
]);
|
||||
|
||||
return $this->processAccessTokenCallback($accessToken, $settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws OidcIssuerDiscoveryException
|
||||
* @throws ClientExceptionInterface
|
||||
*/
|
||||
protected function getProviderSettings(): OidcProviderSettings
|
||||
{
|
||||
$config = $this->config();
|
||||
$settings = new OidcProviderSettings([
|
||||
'issuer' => $config['issuer'],
|
||||
'clientId' => $config['client_id'],
|
||||
'clientSecret' => $config['client_secret'],
|
||||
'redirectUri' => url('/oidc/callback'),
|
||||
'authorizationEndpoint' => $config['authorization_endpoint'],
|
||||
'tokenEndpoint' => $config['token_endpoint'],
|
||||
]);
|
||||
|
||||
// Use keys if configured
|
||||
if (!empty($config['jwt_public_key'])) {
|
||||
$settings->keys = [$config['jwt_public_key']];
|
||||
}
|
||||
|
||||
// Run discovery
|
||||
if ($config['discover'] ?? false) {
|
||||
$settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15);
|
||||
}
|
||||
|
||||
$settings->validate();
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the underlying OpenID Connect Provider.
|
||||
*/
|
||||
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
|
||||
{
|
||||
return new OidcOAuthProvider($settings->arrayForProvider(), [
|
||||
'httpClient' => $this->httpClient,
|
||||
'optionProvider' => new HttpBasicAuthOptionProvider(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the display name.
|
||||
*/
|
||||
protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string
|
||||
{
|
||||
$displayNameAttr = $this->config()['display_name_claims'];
|
||||
|
||||
$displayName = [];
|
||||
foreach ($displayNameAttr as $dnAttr) {
|
||||
$dnComponent = $token->getClaim($dnAttr) ?? '';
|
||||
if ($dnComponent !== '') {
|
||||
$displayName[] = $dnComponent;
|
||||
}
|
||||
}
|
||||
|
||||
if (count($displayName) == 0) {
|
||||
$displayName[] = $defaultValue;
|
||||
}
|
||||
|
||||
return implode(' ', $displayName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the details of a user from an ID token.
|
||||
*
|
||||
* @return array{name: string, email: string, external_id: string}
|
||||
*/
|
||||
protected function getUserDetails(OidcIdToken $token): array
|
||||
{
|
||||
$id = $token->getClaim('sub');
|
||||
|
||||
return [
|
||||
'external_id' => $id,
|
||||
'email' => $token->getClaim('email'),
|
||||
'name' => $this->getUserDisplayName($token, $id),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a received access token for a user. Login the user when
|
||||
* they exist, optionally registering them automatically.
|
||||
*
|
||||
* @throws OpenIdConnectException
|
||||
* @throws JsonDebugException
|
||||
* @throws UserRegistrationException
|
||||
* @throws StoppedAuthenticationException
|
||||
*/
|
||||
protected function processAccessTokenCallback(OidcAccessToken $accessToken, OidcProviderSettings $settings): User
|
||||
{
|
||||
$idTokenText = $accessToken->getIdToken();
|
||||
$idToken = new OidcIdToken(
|
||||
$idTokenText,
|
||||
$settings->issuer,
|
||||
$settings->keys,
|
||||
);
|
||||
|
||||
if ($this->config()['dump_user_details']) {
|
||||
throw new JsonDebugException($idToken->getAllClaims());
|
||||
}
|
||||
|
||||
try {
|
||||
$idToken->validate($settings->clientId);
|
||||
} catch (OidcInvalidTokenException $exception) {
|
||||
throw new OpenIdConnectException("ID token validate failed with error: {$exception->getMessage()}");
|
||||
}
|
||||
|
||||
$userDetails = $this->getUserDetails($idToken);
|
||||
$isLoggedIn = auth()->check();
|
||||
|
||||
if (empty($userDetails['email'])) {
|
||||
throw new OpenIdConnectException(trans('errors.oidc_no_email_address'));
|
||||
}
|
||||
|
||||
if ($isLoggedIn) {
|
||||
throw new OpenIdConnectException(trans('errors.oidc_already_logged_in'), '/login');
|
||||
}
|
||||
|
||||
$user = $this->registrationService->findOrRegister(
|
||||
$userDetails['name'],
|
||||
$userDetails['email'],
|
||||
$userDetails['external_id']
|
||||
);
|
||||
|
||||
if ($user === null) {
|
||||
throw new OpenIdConnectException(trans('errors.oidc_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
|
||||
}
|
||||
|
||||
$this->loginService->login($user, 'oidc');
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the OIDC config from the application.
|
||||
*/
|
||||
protected function config(): array
|
||||
{
|
||||
return config('oidc');
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use BookStack\Facades\Activity;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use Exception;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class RegistrationService
|
||||
{
|
||||
@@ -50,6 +51,32 @@ class RegistrationService
|
||||
return in_array($authMethod, $authMethodsWithRegistration) && setting('registration-enabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to find a user in the system otherwise register them as a new
|
||||
* user. For use with external auth systems since password is auto-generated.
|
||||
*
|
||||
* @throws UserRegistrationException
|
||||
*/
|
||||
public function findOrRegister(string $name, string $email, string $externalId): User
|
||||
{
|
||||
$user = User::query()
|
||||
->where('external_auth_id', '=', $externalId)
|
||||
->first();
|
||||
|
||||
if (is_null($user)) {
|
||||
$userData = [
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => Str::random(32),
|
||||
'external_auth_id' => $externalId,
|
||||
];
|
||||
|
||||
$user = $this->registerUser($userData, null, false);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* The registrations flow for all users.
|
||||
*
|
||||
|
||||
@@ -8,8 +8,8 @@ use BookStack\Exceptions\SamlException;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use Exception;
|
||||
use Illuminate\Support\Str;
|
||||
use OneLogin\Saml2\Auth;
|
||||
use OneLogin\Saml2\Constants;
|
||||
use OneLogin\Saml2\Error;
|
||||
use OneLogin\Saml2\IdPMetadataParser;
|
||||
use OneLogin\Saml2\ValidationError;
|
||||
@@ -18,20 +18,25 @@ use OneLogin\Saml2\ValidationError;
|
||||
* Class Saml2Service
|
||||
* Handles any app-specific SAML tasks.
|
||||
*/
|
||||
class Saml2Service extends ExternalAuthService
|
||||
class Saml2Service
|
||||
{
|
||||
protected $config;
|
||||
protected $registrationService;
|
||||
protected $loginService;
|
||||
protected $groupSyncService;
|
||||
|
||||
/**
|
||||
* Saml2Service constructor.
|
||||
*/
|
||||
public function __construct(RegistrationService $registrationService, LoginService $loginService)
|
||||
{
|
||||
public function __construct(
|
||||
RegistrationService $registrationService,
|
||||
LoginService $loginService,
|
||||
GroupSyncService $groupSyncService
|
||||
) {
|
||||
$this->config = config('saml2');
|
||||
$this->registrationService = $registrationService;
|
||||
$this->loginService = $loginService;
|
||||
$this->groupSyncService = $groupSyncService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,13 +60,20 @@ class Saml2Service extends ExternalAuthService
|
||||
*
|
||||
* @throws Error
|
||||
*/
|
||||
public function logout(): array
|
||||
public function logout(User $user): array
|
||||
{
|
||||
$toolKit = $this->getToolkit();
|
||||
$returnRoute = url('/');
|
||||
|
||||
try {
|
||||
$url = $toolKit->logout($returnRoute, [], null, null, true);
|
||||
$url = $toolKit->logout(
|
||||
$returnRoute,
|
||||
[],
|
||||
$user->email,
|
||||
null,
|
||||
true,
|
||||
Constants::NAMEID_EMAIL_ADDRESS
|
||||
);
|
||||
$id = $toolKit->getLastRequestID();
|
||||
} catch (Error $error) {
|
||||
if ($error->getCode() !== Error::SAML_SINGLE_LOGOUT_NOT_SUPPORTED) {
|
||||
@@ -87,8 +99,11 @@ class Saml2Service extends ExternalAuthService
|
||||
* @throws JsonDebugException
|
||||
* @throws UserRegistrationException
|
||||
*/
|
||||
public function processAcsResponse(?string $requestId): ?User
|
||||
public function processAcsResponse(string $requestId, string $samlResponse): ?User
|
||||
{
|
||||
// The SAML2 toolkit expects the response to be within the $_POST superglobal
|
||||
// so we need to manually put it back there at this point.
|
||||
$_POST['SAMLResponse'] = $samlResponse;
|
||||
$toolkit = $this->getToolkit();
|
||||
$toolkit->processResponse($requestId);
|
||||
$errors = $toolkit->getErrors();
|
||||
@@ -117,8 +132,13 @@ class Saml2Service extends ExternalAuthService
|
||||
public function processSlsResponse(?string $requestId): ?string
|
||||
{
|
||||
$toolkit = $this->getToolkit();
|
||||
$redirect = $toolkit->processSLO(true, $requestId, false, null, true);
|
||||
|
||||
// The $retrieveParametersFromServer in the call below will mean the library will take the query
|
||||
// parameters, used for the response signing, from the raw $_SERVER['QUERY_STRING']
|
||||
// value so that the exact encoding format is matched when checking the signature.
|
||||
// This is primarily due to ADFS encoding query params with lowercase percent encoding while
|
||||
// PHP (And most other sensible providers) standardise on uppercase.
|
||||
$redirect = $toolkit->processSLO(true, $requestId, true, null, true);
|
||||
$errors = $toolkit->getErrors();
|
||||
|
||||
if (!empty($errors)) {
|
||||
@@ -258,6 +278,8 @@ class Saml2Service extends ExternalAuthService
|
||||
|
||||
/**
|
||||
* Extract the details of a user from a SAML response.
|
||||
*
|
||||
* @return array{external_id: string, name: string, email: string, saml_id: string}
|
||||
*/
|
||||
protected function getUserDetails(string $samlID, $samlAttributes): array
|
||||
{
|
||||
@@ -322,31 +344,6 @@ class Saml2Service extends ExternalAuthService
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user from the database for the specified details.
|
||||
*
|
||||
* @throws UserRegistrationException
|
||||
*/
|
||||
protected function getOrRegisterUser(array $userDetails): ?User
|
||||
{
|
||||
$user = User::query()
|
||||
->where('external_auth_id', '=', $userDetails['external_id'])
|
||||
->first();
|
||||
|
||||
if (is_null($user)) {
|
||||
$userData = [
|
||||
'name' => $userDetails['name'],
|
||||
'email' => $userDetails['email'],
|
||||
'password' => Str::random(32),
|
||||
'external_auth_id' => $userDetails['external_id'],
|
||||
];
|
||||
|
||||
$user = $this->registrationService->registerUser($userData, null, false);
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the SAML response for a user. Login the user when
|
||||
* they exist, optionally registering them automatically.
|
||||
@@ -377,14 +374,19 @@ class Saml2Service extends ExternalAuthService
|
||||
throw new SamlException(trans('errors.saml_already_logged_in'), '/login');
|
||||
}
|
||||
|
||||
$user = $this->getOrRegisterUser($userDetails);
|
||||
$user = $this->registrationService->findOrRegister(
|
||||
$userDetails['name'],
|
||||
$userDetails['email'],
|
||||
$userDetails['external_id']
|
||||
);
|
||||
|
||||
if ($user === null) {
|
||||
throw new SamlException(trans('errors.saml_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
|
||||
}
|
||||
|
||||
if ($this->shouldSyncGroups()) {
|
||||
$groups = $this->getUserGroups($samlAttributes);
|
||||
$this->syncWithGroups($user, $groups);
|
||||
$this->groupSyncService->syncUserWithFoundGroups($user, $groups, $this->config['remove_from_groups']);
|
||||
}
|
||||
|
||||
$this->loginService->login($user, 'saml2');
|
||||
|
||||
@@ -281,9 +281,6 @@ class SocialAuthService
|
||||
if ($driverName === 'google' && config('services.google.select_account')) {
|
||||
$driver->with(['prompt' => 'select_account']);
|
||||
}
|
||||
if ($driverName === 'azure') {
|
||||
$driver->with(['resource' => 'https://graph.windows.net']);
|
||||
}
|
||||
|
||||
if (isset($this->configureForRedirectCallbacks[$driverName])) {
|
||||
$this->configureForRedirectCallbacks[$driverName]($driver);
|
||||
|
||||
@@ -27,7 +27,7 @@ use Illuminate\Support\Collection;
|
||||
/**
|
||||
* Class User.
|
||||
*
|
||||
* @property string $id
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $slug
|
||||
* @property string $email
|
||||
|
||||
@@ -61,7 +61,7 @@ return [
|
||||
'locale' => env('APP_LANG', 'en'),
|
||||
|
||||
// Locales available
|
||||
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW'],
|
||||
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'et', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW'],
|
||||
|
||||
// Application Fallback Locale
|
||||
'fallback_locale' => 'en',
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
return [
|
||||
|
||||
// Method of authentication to use
|
||||
// Options: standard, ldap, saml2
|
||||
// Options: standard, ldap, saml2, oidc
|
||||
'method' => env('AUTH_METHOD', 'standard'),
|
||||
|
||||
// Authentication Defaults
|
||||
@@ -26,7 +26,7 @@ return [
|
||||
// All authentication drivers have a user provider. This defines how the
|
||||
// users are actually retrieved out of your database or other storage
|
||||
// mechanisms used by this application to persist your user's data.
|
||||
// Supported drivers: "session", "api-token", "ldap-session"
|
||||
// Supported drivers: "session", "api-token", "ldap-session", "async-external-session"
|
||||
'guards' => [
|
||||
'standard' => [
|
||||
'driver' => 'session',
|
||||
@@ -37,7 +37,11 @@ return [
|
||||
'provider' => 'external',
|
||||
],
|
||||
'saml2' => [
|
||||
'driver' => 'saml2-session',
|
||||
'driver' => 'async-external-session',
|
||||
'provider' => 'external',
|
||||
],
|
||||
'oidc' => [
|
||||
'driver' => 'async-external-session',
|
||||
'provider' => 'external',
|
||||
],
|
||||
'api' => [
|
||||
@@ -70,6 +74,7 @@ return [
|
||||
'email' => 'emails.password',
|
||||
'table' => 'password_resets',
|
||||
'expire' => 60,
|
||||
'throttle' => 60,
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ return [
|
||||
* direct class use like:
|
||||
* $dompdf = new DOMPDF(); $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output();
|
||||
*/
|
||||
'chroot' => realpath(base_path()),
|
||||
'chroot' => realpath(public_path()),
|
||||
|
||||
/**
|
||||
* Whether to use Unicode fonts or not.
|
||||
|
||||
35
app/Config/oidc.php
Normal file
35
app/Config/oidc.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
// Display name, shown to users, for OpenId option
|
||||
'name' => env('OIDC_NAME', 'SSO'),
|
||||
|
||||
// Dump user details after a login request for debugging purposes
|
||||
'dump_user_details' => env('OIDC_DUMP_USER_DETAILS', false),
|
||||
|
||||
// Attribute, within a OpenId token, to find the user's display name
|
||||
'display_name_claims' => explode('|', env('OIDC_DISPLAY_NAME_CLAIMS', 'name')),
|
||||
|
||||
// OAuth2/OpenId client id, as configured in your Authorization server.
|
||||
'client_id' => env('OIDC_CLIENT_ID', null),
|
||||
|
||||
// OAuth2/OpenId client secret, as configured in your Authorization server.
|
||||
'client_secret' => env('OIDC_CLIENT_SECRET', null),
|
||||
|
||||
// The issuer of the identity token (id_token) this will be compared with
|
||||
// what is returned in the token.
|
||||
'issuer' => env('OIDC_ISSUER', null),
|
||||
|
||||
// Auto-discover the relevant endpoints and keys from the issuer.
|
||||
// Fetched details are cached for 15 minutes.
|
||||
'discover' => env('OIDC_ISSUER_DISCOVER', false),
|
||||
|
||||
// Public key that's used to verify the JWT token with.
|
||||
// Can be the key value itself or a local 'file://public.key' reference.
|
||||
'jwt_public_key' => env('OIDC_PUBLIC_KEY', null),
|
||||
|
||||
// OAuth2 endpoints.
|
||||
'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null),
|
||||
'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null),
|
||||
];
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
$SAML2_IDP_AUTHNCONTEXT = env('SAML2_IDP_AUTHNCONTEXT', true);
|
||||
$SAML2_SP_x509 = env('SAML2_SP_x509', false);
|
||||
|
||||
return [
|
||||
|
||||
@@ -78,10 +79,11 @@ return [
|
||||
// represent the requested subject.
|
||||
// Take a look on lib/Saml2/Constants.php to see the NameIdFormat supported
|
||||
'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
||||
|
||||
// Usually x509cert and privateKey of the SP are provided by files placed at
|
||||
// the certs folder. But we can also provide them with the following parameters
|
||||
'x509cert' => '',
|
||||
'privateKey' => '',
|
||||
'x509cert' => $SAML2_SP_x509 ?: '',
|
||||
'privateKey' => env('SAML2_SP_x509_KEY', ''),
|
||||
],
|
||||
// Identity Provider Data that we want connect with our SP
|
||||
'idp' => [
|
||||
@@ -147,6 +149,11 @@ return [
|
||||
// Multiple forced values can be passed via a space separated array, For example:
|
||||
// SAML2_IDP_AUTHNCONTEXT="urn:federation:authentication:windows urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"
|
||||
'requestedAuthnContext' => is_string($SAML2_IDP_AUTHNCONTEXT) ? explode(' ', $SAML2_IDP_AUTHNCONTEXT) : $SAML2_IDP_AUTHNCONTEXT,
|
||||
// Sign requests and responses if a certificate is in use
|
||||
'logoutRequestSigned' => (bool) $SAML2_SP_x509,
|
||||
'logoutResponseSigned' => (bool) $SAML2_SP_x509,
|
||||
'authnRequestsSigned' => (bool) $SAML2_SP_x509,
|
||||
'lowercaseUrlencoding' => false,
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ class Page extends BookChild
|
||||
public static $listAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'text', 'created_at', 'updated_at', 'priority'];
|
||||
public static $contentAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'html', 'text', 'created_at', 'updated_at', 'priority'];
|
||||
|
||||
protected $fillable = ['name', 'priority', 'markdown'];
|
||||
protected $fillable = ['name', 'priority'];
|
||||
|
||||
public $textField = 'text';
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use DOMDocument;
|
||||
use DOMNodeList;
|
||||
@@ -37,7 +38,7 @@ class PageContent
|
||||
*/
|
||||
public function setNewHTML(string $html)
|
||||
{
|
||||
$html = $this->extractBase64Images($this->page, $html);
|
||||
$html = $this->extractBase64ImagesFromHtml($html);
|
||||
$this->page->html = $this->formatHtml($html);
|
||||
$this->page->text = $this->toPlainText();
|
||||
$this->page->markdown = '';
|
||||
@@ -48,6 +49,7 @@ class PageContent
|
||||
*/
|
||||
public function setNewMarkdown(string $markdown)
|
||||
{
|
||||
$markdown = $this->extractBase64ImagesFromMarkdown($markdown);
|
||||
$this->page->markdown = $markdown;
|
||||
$html = $this->markdownToHtml($markdown);
|
||||
$this->page->html = $this->formatHtml($html);
|
||||
@@ -74,7 +76,7 @@ class PageContent
|
||||
/**
|
||||
* Convert all base64 image data to saved images.
|
||||
*/
|
||||
public function extractBase64Images(Page $page, string $htmlText): string
|
||||
protected function extractBase64ImagesFromHtml(string $htmlText): string
|
||||
{
|
||||
if (empty($htmlText) || strpos($htmlText, 'data:image') === false) {
|
||||
return $htmlText;
|
||||
@@ -85,31 +87,13 @@ class PageContent
|
||||
$body = $container->childNodes->item(0);
|
||||
$childNodes = $body->childNodes;
|
||||
$xPath = new DOMXPath($doc);
|
||||
$imageRepo = app()->make(ImageRepo::class);
|
||||
$allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
|
||||
// Get all img elements with image data blobs
|
||||
$imageNodes = $xPath->query('//img[contains(@src, \'data:image\')]');
|
||||
foreach ($imageNodes as $imageNode) {
|
||||
$imageSrc = $imageNode->getAttribute('src');
|
||||
[$dataDefinition, $base64ImageData] = explode(',', $imageSrc, 2);
|
||||
$extension = strtolower(preg_split('/[\/;]/', $dataDefinition)[1] ?? 'png');
|
||||
|
||||
// Validate extension
|
||||
if (!in_array($extension, $allowedExtensions)) {
|
||||
$imageNode->setAttribute('src', '');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Save image from data with a random name
|
||||
$imageName = 'embedded-image-' . Str::random(8) . '.' . $extension;
|
||||
|
||||
try {
|
||||
$image = $imageRepo->saveNewFromData($imageName, base64_decode($base64ImageData), 'gallery', $page->id);
|
||||
$imageNode->setAttribute('src', $image->url);
|
||||
} catch (ImageUploadException $exception) {
|
||||
$imageNode->setAttribute('src', '');
|
||||
}
|
||||
$newUrl = $this->base64ImageUriToUploadedImageUrl($imageSrc);
|
||||
$imageNode->setAttribute('src', $newUrl);
|
||||
}
|
||||
|
||||
// Generate inner html as a string
|
||||
@@ -121,6 +105,64 @@ class PageContent
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert all inline base64 content to uploaded image files.
|
||||
*/
|
||||
protected function extractBase64ImagesFromMarkdown(string $markdown)
|
||||
{
|
||||
$matches = [];
|
||||
preg_match_all('/!\[.*?]\(.*?(data:image\/.*?)[)"\s]/', $markdown, $matches);
|
||||
|
||||
foreach ($matches[1] as $base64Match) {
|
||||
$newUrl = $this->base64ImageUriToUploadedImageUrl($base64Match);
|
||||
$markdown = str_replace($base64Match, $newUrl, $markdown);
|
||||
}
|
||||
|
||||
return $markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the given base64 image URI and return the URL to the created image instance.
|
||||
* Returns an empty string if the parsed URI is invalid or causes an error upon upload.
|
||||
*/
|
||||
protected function base64ImageUriToUploadedImageUrl(string $uri): string
|
||||
{
|
||||
$imageRepo = app()->make(ImageRepo::class);
|
||||
$imageInfo = $this->parseBase64ImageUri($uri);
|
||||
|
||||
// Validate extension and content
|
||||
if (empty($imageInfo['data']) || !ImageService::isExtensionSupported($imageInfo['extension'])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Save image from data with a random name
|
||||
$imageName = 'embedded-image-' . Str::random(8) . '.' . $imageInfo['extension'];
|
||||
|
||||
try {
|
||||
$image = $imageRepo->saveNewFromData($imageName, $imageInfo['data'], 'gallery', $this->page->id);
|
||||
} catch (ImageUploadException $exception) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $image->url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a base64 image URI into the data and extension.
|
||||
*
|
||||
* @return array{extension: array, data: string}
|
||||
*/
|
||||
protected function parseBase64ImageUri(string $uri): array
|
||||
{
|
||||
[$dataDefinition, $base64ImageData] = explode(',', $uri, 2);
|
||||
$extension = strtolower(preg_split('/[\/;]/', $dataDefinition)[1] ?? '');
|
||||
|
||||
return [
|
||||
'extension' => $extension,
|
||||
'data' => base64_decode($base64ImageData) ?: '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a page's html to be tagged correctly within the system.
|
||||
*/
|
||||
|
||||
7
app/Exceptions/OpenIdConnectException.php
Normal file
7
app/Exceptions/OpenIdConnectException.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exceptions;
|
||||
|
||||
class OpenIdConnectException extends NotifyException
|
||||
{
|
||||
}
|
||||
49
app/Exceptions/WhoopsBookStackPrettyHandler.php
Normal file
49
app/Exceptions/WhoopsBookStackPrettyHandler.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exceptions;
|
||||
|
||||
use Whoops\Handler\Handler;
|
||||
|
||||
class WhoopsBookStackPrettyHandler extends Handler
|
||||
{
|
||||
/**
|
||||
* @return int|null A handler may return nothing, or a Handler::HANDLE_* constant
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$exception = $this->getException();
|
||||
|
||||
echo view('errors.debug', [
|
||||
'error' => $exception->getMessage(),
|
||||
'errorClass' => get_class($exception),
|
||||
'trace' => $exception->getTraceAsString(),
|
||||
'environment' => $this->getEnvironment(),
|
||||
])->render();
|
||||
|
||||
return Handler::QUIT;
|
||||
}
|
||||
|
||||
protected function safeReturn(callable $callback, $default = null)
|
||||
{
|
||||
try {
|
||||
return $callback();
|
||||
} catch (\Exception $e) {
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
protected function getEnvironment(): array
|
||||
{
|
||||
return [
|
||||
'PHP Version' => phpversion(),
|
||||
'BookStack Version' => $this->safeReturn(function () {
|
||||
$versionFile = base_path('version');
|
||||
|
||||
return trim(file_get_contents($versionFile));
|
||||
}, 'unknown'),
|
||||
'Theme Configured' => $this->safeReturn(function () {
|
||||
return config('view.theme');
|
||||
}) ?? 'None',
|
||||
];
|
||||
}
|
||||
}
|
||||
165
app/Http/Controllers/Api/AttachmentApiController.php
Normal file
165
app/Http/Controllers/Api/AttachmentApiController.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers\Api;
|
||||
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Exceptions\FileUploadException;
|
||||
use BookStack\Uploads\Attachment;
|
||||
use BookStack\Uploads\AttachmentService;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Filesystem\FileNotFoundException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AttachmentApiController extends ApiController
|
||||
{
|
||||
protected $attachmentService;
|
||||
|
||||
protected $rules = [
|
||||
'create' => [
|
||||
'name' => 'required|min:1|max:255|string',
|
||||
'uploaded_to' => 'required|integer|exists:pages,id',
|
||||
'file' => 'required_without:link|file',
|
||||
'link' => 'required_without:file|min:1|max:255|safe_url',
|
||||
],
|
||||
'update' => [
|
||||
'name' => 'min:1|max:255|string',
|
||||
'uploaded_to' => 'integer|exists:pages,id',
|
||||
'file' => 'file',
|
||||
'link' => 'min:1|max:255|safe_url',
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(AttachmentService $attachmentService)
|
||||
{
|
||||
$this->attachmentService = $attachmentService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a listing of attachments visible to the user.
|
||||
* The external property indicates whether the attachment is simple a link.
|
||||
* A false value for the external property would indicate a file upload.
|
||||
*/
|
||||
public function list()
|
||||
{
|
||||
return $this->apiListingResponse(Attachment::visible(), [
|
||||
'id', 'name', 'extension', 'uploaded_to', 'external', 'order', 'created_at', 'updated_at', 'created_by', 'updated_by',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new attachment in the system.
|
||||
* An uploaded_to value must be provided containing an ID of the page
|
||||
* that this upload will be related to.
|
||||
*
|
||||
* If you're uploading a file the POST data should be provided via
|
||||
* a multipart/form-data type request instead of JSON.
|
||||
*
|
||||
* @throws ValidationException
|
||||
* @throws FileUploadException
|
||||
*/
|
||||
public function create(Request $request)
|
||||
{
|
||||
$this->checkPermission('attachment-create-all');
|
||||
$requestData = $this->validate($request, $this->rules['create']);
|
||||
|
||||
$pageId = $request->get('uploaded_to');
|
||||
$page = Page::visible()->findOrFail($pageId);
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
|
||||
if ($request->hasFile('file')) {
|
||||
$uploadedFile = $request->file('file');
|
||||
$attachment = $this->attachmentService->saveNewUpload($uploadedFile, $page->id);
|
||||
} else {
|
||||
$attachment = $this->attachmentService->saveNewFromLink(
|
||||
$requestData['name'],
|
||||
$requestData['link'],
|
||||
$page->id
|
||||
);
|
||||
}
|
||||
|
||||
$this->attachmentService->updateFile($attachment, $requestData);
|
||||
|
||||
return response()->json($attachment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the details & content of a single attachment of the given ID.
|
||||
* The attachment link or file content is provided via a 'content' property.
|
||||
* For files the content will be base64 encoded.
|
||||
*
|
||||
* @throws FileNotFoundException
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
/** @var Attachment $attachment */
|
||||
$attachment = Attachment::visible()
|
||||
->with(['createdBy', 'updatedBy'])
|
||||
->findOrFail($id);
|
||||
|
||||
$attachment->setAttribute('links', [
|
||||
'html' => $attachment->htmlLink(),
|
||||
'markdown' => $attachment->markdownLink(),
|
||||
]);
|
||||
|
||||
if (!$attachment->external) {
|
||||
$attachmentContents = $this->attachmentService->getAttachmentFromStorage($attachment);
|
||||
$attachment->setAttribute('content', base64_encode($attachmentContents));
|
||||
} else {
|
||||
$attachment->setAttribute('content', $attachment->path);
|
||||
}
|
||||
|
||||
return response()->json($attachment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the details of a single attachment.
|
||||
* As per the create endpoint, if a file is being provided as the attachment content
|
||||
* the request should be formatted as a multipart/form-data request instead of JSON.
|
||||
*
|
||||
* @throws ValidationException
|
||||
* @throws FileUploadException
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$requestData = $this->validate($request, $this->rules['update']);
|
||||
/** @var Attachment $attachment */
|
||||
$attachment = Attachment::visible()->findOrFail($id);
|
||||
|
||||
$page = $attachment->page;
|
||||
if ($requestData['uploaded_to'] ?? false) {
|
||||
$pageId = $request->get('uploaded_to');
|
||||
$page = Page::visible()->findOrFail($pageId);
|
||||
$attachment->uploaded_to = $requestData['uploaded_to'];
|
||||
}
|
||||
|
||||
$this->checkOwnablePermission('page-view', $page);
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
$this->checkOwnablePermission('attachment-update', $attachment);
|
||||
|
||||
if ($request->hasFile('file')) {
|
||||
$uploadedFile = $request->file('file');
|
||||
$attachment = $this->attachmentService->saveUpdatedUpload($uploadedFile, $attachment);
|
||||
}
|
||||
|
||||
$this->attachmentService->updateFile($attachment, $requestData);
|
||||
|
||||
return response()->json($attachment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an attachment of the given ID.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function delete(string $id)
|
||||
{
|
||||
/** @var Attachment $attachment */
|
||||
$attachment = Attachment::visible()->findOrFail($id);
|
||||
$this->checkOwnablePermission('attachment-delete', $attachment);
|
||||
|
||||
$this->attachmentService->deleteFile($attachment);
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@ class AttachmentController extends Controller
|
||||
'file' => 'required|file',
|
||||
]);
|
||||
|
||||
/** @var Attachment $attachment */
|
||||
$attachment = Attachment::query()->findOrFail($attachmentId);
|
||||
$this->checkOwnablePermission('view', $attachment->page);
|
||||
$this->checkOwnablePermission('page-update', $attachment->page);
|
||||
@@ -86,11 +87,10 @@ class AttachmentController extends Controller
|
||||
|
||||
/**
|
||||
* Get the update form for an attachment.
|
||||
*
|
||||
* @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\View\Factory|\Illuminate\View\View
|
||||
*/
|
||||
public function getUpdateForm(string $attachmentId)
|
||||
{
|
||||
/** @var Attachment $attachment */
|
||||
$attachment = Attachment::query()->findOrFail($attachmentId);
|
||||
|
||||
$this->checkOwnablePermission('page-update', $attachment->page);
|
||||
@@ -121,9 +121,9 @@ class AttachmentController extends Controller
|
||||
]), 422);
|
||||
}
|
||||
|
||||
$this->checkOwnablePermission('view', $attachment->page);
|
||||
$this->checkOwnablePermission('page-view', $attachment->page);
|
||||
$this->checkOwnablePermission('page-update', $attachment->page);
|
||||
$this->checkOwnablePermission('attachment-create', $attachment);
|
||||
$this->checkOwnablePermission('attachment-update', $attachment);
|
||||
|
||||
$attachment = $this->attachmentService->updateFile($attachment, [
|
||||
'name' => $request->get('attachment_edit_name'),
|
||||
@@ -173,6 +173,8 @@ class AttachmentController extends Controller
|
||||
|
||||
/**
|
||||
* Get the attachments for a specific page.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function listForPage(int $pageId)
|
||||
{
|
||||
|
||||
@@ -56,7 +56,7 @@ class ForgotPasswordController extends Controller
|
||||
$this->logActivity(ActivityType::AUTH_PASSWORD_RESET, $request->get('email'));
|
||||
}
|
||||
|
||||
if ($response === Password::RESET_LINK_SENT || $response === Password::INVALID_USER) {
|
||||
if (in_array($response, [Password::RESET_LINK_SENT, Password::INVALID_USER, Password::RESET_THROTTLED])) {
|
||||
$message = trans('auth.reset_password_sent', ['email' => $request->get('email')]);
|
||||
$this->showSuccessNotification($message);
|
||||
|
||||
|
||||
@@ -43,7 +43,8 @@ class LoginController extends Controller
|
||||
public function __construct(SocialAuthService $socialAuthService, LoginService $loginService)
|
||||
{
|
||||
$this->middleware('guest', ['only' => ['getLogin', 'login']]);
|
||||
$this->middleware('guard:standard,ldap', ['only' => ['login', 'logout']]);
|
||||
$this->middleware('guard:standard,ldap', ['only' => ['login']]);
|
||||
$this->middleware('guard:standard,ldap,oidc', ['only' => ['logout']]);
|
||||
|
||||
$this->socialAuthService = $socialAuthService;
|
||||
$this->loginService = $loginService;
|
||||
|
||||
@@ -31,7 +31,7 @@ class MfaTotpController extends Controller
|
||||
session()->put(static::SETUP_SECRET_SESSION_KEY, encrypt($totpSecret));
|
||||
}
|
||||
|
||||
$qrCodeUrl = $totp->generateUrl($totpSecret);
|
||||
$qrCodeUrl = $totp->generateUrl($totpSecret, $this->currentOrLastAttemptedUser());
|
||||
$svg = $totp->generateQrCodeSvg($qrCodeUrl);
|
||||
|
||||
return view('mfa.totp-generate', [
|
||||
|
||||
52
app/Http/Controllers/Auth/OidcController.php
Normal file
52
app/Http/Controllers/Auth/OidcController.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers\Auth;
|
||||
|
||||
use BookStack\Auth\Access\Oidc\OidcService;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class OidcController extends Controller
|
||||
{
|
||||
protected $oidcService;
|
||||
|
||||
/**
|
||||
* OpenIdController constructor.
|
||||
*/
|
||||
public function __construct(OidcService $oidcService)
|
||||
{
|
||||
$this->oidcService = $oidcService;
|
||||
$this->middleware('guard:oidc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the authorization login flow via OIDC.
|
||||
*/
|
||||
public function login()
|
||||
{
|
||||
$loginDetails = $this->oidcService->login();
|
||||
session()->flash('oidc_state', $loginDetails['state']);
|
||||
|
||||
return redirect($loginDetails['url']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorization flow redirect callback.
|
||||
* Processes authorization response from the OIDC Authorization Server.
|
||||
*/
|
||||
public function callback(Request $request)
|
||||
{
|
||||
$storedState = session()->pull('oidc_state');
|
||||
$responseState = $request->query('state');
|
||||
|
||||
if ($storedState !== $responseState) {
|
||||
$this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')]));
|
||||
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
$this->oidcService->processAuthorizeResponse($request->query('code'));
|
||||
|
||||
return redirect()->intended();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ namespace BookStack\Http\Controllers\Auth;
|
||||
|
||||
use BookStack\Auth\Access\Saml2Service;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Str;
|
||||
|
||||
class Saml2Controller extends Controller
|
||||
{
|
||||
@@ -34,7 +37,7 @@ class Saml2Controller extends Controller
|
||||
*/
|
||||
public function logout()
|
||||
{
|
||||
$logoutDetails = $this->samlService->logout();
|
||||
$logoutDetails = $this->samlService->logout(auth()->user());
|
||||
|
||||
if ($logoutDetails['id']) {
|
||||
session()->flash('saml2_logout_request_id', $logoutDetails['id']);
|
||||
@@ -68,15 +71,59 @@ class Saml2Controller extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Assertion Consumer Service.
|
||||
* Processes the SAML response from the IDP.
|
||||
* Assertion Consumer Service start URL. Takes the SAMLResponse from the IDP.
|
||||
* Due to being an external POST request, we likely won't have context of the
|
||||
* current user session due to lax cookies. To work around this we store the
|
||||
* SAMLResponse data and redirect to the processAcs endpoint for the actual
|
||||
* processing of the request with proper context of the user session.
|
||||
*/
|
||||
public function acs()
|
||||
public function startAcs(Request $request)
|
||||
{
|
||||
$requestId = session()->pull('saml2_request_id', null);
|
||||
// Note: This is a bit of a hack to prevent a session being stored
|
||||
// on the response of this request. Within Laravel7+ this could instead
|
||||
// be done via removing the StartSession middleware from the route.
|
||||
config()->set('session.driver', 'array');
|
||||
|
||||
$user = $this->samlService->processAcsResponse($requestId);
|
||||
if ($user === null) {
|
||||
$samlResponse = $request->get('SAMLResponse', null);
|
||||
|
||||
if (empty($samlResponse)) {
|
||||
$this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
|
||||
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
$acsId = Str::random(16);
|
||||
$cacheKey = 'saml2_acs:' . $acsId;
|
||||
cache()->set($cacheKey, encrypt($samlResponse), 10);
|
||||
|
||||
return redirect()->guest('/saml2/acs?id=' . $acsId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assertion Consumer Service process endpoint.
|
||||
* Processes the SAML response from the IDP with context of the current session.
|
||||
* Takes the SAML request from the cache, added by the startAcs method above.
|
||||
*/
|
||||
public function processAcs(Request $request)
|
||||
{
|
||||
$acsId = $request->get('id', null);
|
||||
$cacheKey = 'saml2_acs:' . $acsId;
|
||||
$samlResponse = null;
|
||||
|
||||
try {
|
||||
$samlResponse = decrypt(cache()->pull($cacheKey));
|
||||
} catch (\Exception $exception) {
|
||||
}
|
||||
$requestId = session()->pull('saml2_request_id', 'unset');
|
||||
|
||||
if (empty($acsId) || empty($samlResponse)) {
|
||||
$this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
|
||||
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
$user = $this->samlService->processAcsResponse($requestId, $samlResponse);
|
||||
if (is_null($user)) {
|
||||
$this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
|
||||
|
||||
return redirect('/login');
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace BookStack\Http\Controllers;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Model;
|
||||
use finfo;
|
||||
use BookStack\Util\WebSafeMimeSniffer;
|
||||
use Illuminate\Foundation\Bus\DispatchesJobs;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
@@ -117,8 +117,9 @@ abstract class Controller extends BaseController
|
||||
protected function downloadResponse(string $content, string $fileName): Response
|
||||
{
|
||||
return response()->make($content, 200, [
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
'Content-Disposition' => 'attachment; filename="' . $fileName . '"',
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
'Content-Disposition' => 'attachment; filename="' . $fileName . '"',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -128,12 +129,12 @@ abstract class Controller extends BaseController
|
||||
*/
|
||||
protected function inlineDownloadResponse(string $content, string $fileName): Response
|
||||
{
|
||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = $finfo->buffer($content) ?: 'application/octet-stream';
|
||||
$mime = (new WebSafeMimeSniffer())->sniff($content);
|
||||
|
||||
return response()->make($content, 200, [
|
||||
'Content-Type' => $mime,
|
||||
'Content-Disposition' => 'inline; filename="' . $fileName . '"',
|
||||
'Content-Type' => $mime,
|
||||
'Content-Disposition' => 'inline; filename="' . $fileName . '"',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,13 +67,12 @@ class DrawioImageController extends Controller
|
||||
public function getAsBase64($id)
|
||||
{
|
||||
$image = $this->imageRepo->getById($id);
|
||||
$page = $image->getPage();
|
||||
if ($image === null || $image->type !== 'drawio' || !userCan('page-view', $page)) {
|
||||
if (is_null($image) || $image->type !== 'drawio' || !userCan('page-view', $image->getPage())) {
|
||||
return $this->jsonError('Image data could not be found');
|
||||
}
|
||||
|
||||
$imageData = $this->imageRepo->getImageData($image);
|
||||
if ($imageData === null) {
|
||||
if (is_null($imageData)) {
|
||||
return $this->jsonError('Image data could not be found');
|
||||
}
|
||||
|
||||
|
||||
@@ -7,25 +7,23 @@ use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use BookStack\Uploads\Image;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use Exception;
|
||||
use Illuminate\Filesystem\Filesystem as File;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ImageController extends Controller
|
||||
{
|
||||
protected $image;
|
||||
protected $file;
|
||||
protected $imageRepo;
|
||||
protected $imageService;
|
||||
|
||||
/**
|
||||
* ImageController constructor.
|
||||
*/
|
||||
public function __construct(Image $image, File $file, ImageRepo $imageRepo)
|
||||
public function __construct(ImageRepo $imageRepo, ImageService $imageService)
|
||||
{
|
||||
$this->image = $image;
|
||||
$this->file = $file;
|
||||
$this->imageRepo = $imageRepo;
|
||||
$this->imageService = $imageService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,14 +33,13 @@ class ImageController extends Controller
|
||||
*/
|
||||
public function showImage(string $path)
|
||||
{
|
||||
$path = storage_path('uploads/images/' . $path);
|
||||
if (!file_exists($path)) {
|
||||
if (!$this->imageService->pathExistsInLocalSecure($path)) {
|
||||
throw (new NotFoundException(trans('errors.image_not_found')))
|
||||
->setSubtitle(trans('errors.image_not_found_subtitle'))
|
||||
->setDetails(trans('errors.image_not_found_details'));
|
||||
}
|
||||
|
||||
return response()->file($path);
|
||||
return $this->imageService->streamImageFromStorageResponse('gallery', $path);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -84,7 +84,7 @@ class UserController extends Controller
|
||||
if ($authMethod === 'standard' && !$sendInvite) {
|
||||
$validationRules['password'] = 'required|min:6';
|
||||
$validationRules['password-confirm'] = 'required|same:password';
|
||||
} elseif ($authMethod === 'ldap' || $authMethod === 'saml2') {
|
||||
} elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') {
|
||||
$validationRules['external_auth_id'] = 'required';
|
||||
}
|
||||
$this->validate($request, $validationRules);
|
||||
@@ -93,7 +93,7 @@ class UserController extends Controller
|
||||
|
||||
if ($authMethod === 'standard') {
|
||||
$user->password = bcrypt($request->get('password', Str::random(32)));
|
||||
} elseif ($authMethod === 'ldap' || $authMethod === 'saml2') {
|
||||
} elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') {
|
||||
$user->external_auth_id = $request->get('external_auth_id');
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ class Localization
|
||||
|
||||
/**
|
||||
* Map of BookStack locale names to best-estimate system locale names.
|
||||
* Locales can often be found by running `locale -a` on a linux system.
|
||||
*/
|
||||
protected $localeMap = [
|
||||
'ar' => 'ar',
|
||||
@@ -27,6 +28,7 @@ class Localization
|
||||
'en' => 'en_GB',
|
||||
'es' => 'es_ES',
|
||||
'es_AR' => 'es_AR',
|
||||
'et' => 'et_EE',
|
||||
'fr' => 'fr_FR',
|
||||
'he' => 'he_IL',
|
||||
'hr' => 'hr_HR',
|
||||
|
||||
@@ -9,9 +9,11 @@ use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Exceptions\WhoopsBookStackPrettyHandler;
|
||||
use BookStack\Settings\Setting;
|
||||
use BookStack\Settings\SettingService;
|
||||
use BookStack\Util\CspService;
|
||||
use GuzzleHttp\Client;
|
||||
use Illuminate\Contracts\Cache\Repository;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
@@ -20,6 +22,8 @@ use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Laravel\Socialite\Contracts\Factory as SocialiteFactory;
|
||||
use Psr\Http\Client\ClientInterface as HttpClientInterface;
|
||||
use Whoops\Handler\HandlerInterface;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -65,6 +69,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$this->app->bind(HandlerInterface::class, function ($app) {
|
||||
return $app->make(WhoopsBookStackPrettyHandler::class);
|
||||
});
|
||||
|
||||
$this->app->singleton(SettingService::class, function ($app) {
|
||||
return new SettingService($app->make(Setting::class), $app->make(Repository::class));
|
||||
});
|
||||
@@ -76,5 +84,11 @@ class AppServiceProvider extends ServiceProvider
|
||||
$this->app->singleton(CspService::class, function ($app) {
|
||||
return new CspService();
|
||||
});
|
||||
|
||||
$this->app->bind(HttpClientInterface::class, function ($app) {
|
||||
return new Client([
|
||||
'timeout' => 3,
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ namespace BookStack\Providers;
|
||||
|
||||
use BookStack\Api\ApiTokenGuard;
|
||||
use BookStack\Auth\Access\ExternalBaseUserProvider;
|
||||
use BookStack\Auth\Access\Guards\AsyncExternalBaseSessionGuard;
|
||||
use BookStack\Auth\Access\Guards\LdapSessionGuard;
|
||||
use BookStack\Auth\Access\Guards\Saml2SessionGuard;
|
||||
use BookStack\Auth\Access\LdapService;
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\Access\RegistrationService;
|
||||
@@ -37,10 +37,10 @@ class AuthServiceProvider extends ServiceProvider
|
||||
);
|
||||
});
|
||||
|
||||
Auth::extend('saml2-session', function ($app, $name, array $config) {
|
||||
Auth::extend('async-external-session', function ($app, $name, array $config) {
|
||||
$provider = Auth::createUserProvider($config['provider']);
|
||||
|
||||
return new Saml2SessionGuard(
|
||||
return new AsyncExternalBaseSessionGuard(
|
||||
$name,
|
||||
$provider,
|
||||
$app['session.store'],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Providers;
|
||||
|
||||
use BookStack\Uploads\ImageService;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
@@ -13,9 +14,9 @@ class CustomValidationServiceProvider extends ServiceProvider
|
||||
public function boot(): void
|
||||
{
|
||||
Validator::extend('image_extension', function ($attribute, $value, $parameters, $validator) {
|
||||
$validImageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp'];
|
||||
$extension = strtolower($value->getClientOriginalExtension());
|
||||
|
||||
return in_array(strtolower($value->getClientOriginalExtension()), $validImageExtensions);
|
||||
return ImageService::isExtensionSupported($extension);
|
||||
});
|
||||
|
||||
Validator::extend('safe_url', function ($attribute, $value, $parameters, $validator) {
|
||||
|
||||
@@ -2,24 +2,37 @@
|
||||
|
||||
namespace BookStack\Uploads;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Model;
|
||||
use BookStack\Traits\HasCreatorAndUpdater;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int id
|
||||
* @property string name
|
||||
* @property string path
|
||||
* @property string extension
|
||||
* @property ?Page page
|
||||
* @property bool external
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $path
|
||||
* @property string $extension
|
||||
* @property ?Page $page
|
||||
* @property bool $external
|
||||
* @property int $uploaded_to
|
||||
* @property User $updatedBy
|
||||
* @property User $createdBy
|
||||
*
|
||||
* @method static Entity|Builder visible()
|
||||
*/
|
||||
class Attachment extends Model
|
||||
{
|
||||
use HasCreatorAndUpdater;
|
||||
|
||||
protected $fillable = ['name', 'order'];
|
||||
protected $hidden = ['path', 'page'];
|
||||
protected $casts = [
|
||||
'external' => 'bool',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the downloadable file name for this upload.
|
||||
@@ -70,4 +83,19 @@ class Attachment extends Model
|
||||
{
|
||||
return '[' . $this->name . '](' . $this->getUrl() . ')';
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope the query to those attachments that are visible based upon related page permissions.
|
||||
*/
|
||||
public function scopeVisible(): Builder
|
||||
{
|
||||
$permissionService = app()->make(PermissionService::class);
|
||||
|
||||
return $permissionService->filterRelatedEntity(
|
||||
Page::class,
|
||||
Attachment::query(),
|
||||
'attachments',
|
||||
'uploaded_to'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ class AttachmentService
|
||||
/**
|
||||
* Get the storage that will be used for storing files.
|
||||
*/
|
||||
protected function getStorage(): FileSystemInstance
|
||||
protected function getStorageDisk(): FileSystemInstance
|
||||
{
|
||||
return $this->fileSystem->disk($this->getStorageDiskName());
|
||||
}
|
||||
@@ -70,7 +70,7 @@ class AttachmentService
|
||||
*/
|
||||
public function getAttachmentFromStorage(Attachment $attachment): string
|
||||
{
|
||||
return $this->getStorage()->get($this->adjustPathForStorageDisk($attachment->path));
|
||||
return $this->getStorageDisk()->get($this->adjustPathForStorageDisk($attachment->path));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,18 +78,18 @@ class AttachmentService
|
||||
*
|
||||
* @throws FileUploadException
|
||||
*/
|
||||
public function saveNewUpload(UploadedFile $uploadedFile, int $page_id): Attachment
|
||||
public function saveNewUpload(UploadedFile $uploadedFile, int $pageId): Attachment
|
||||
{
|
||||
$attachmentName = $uploadedFile->getClientOriginalName();
|
||||
$attachmentPath = $this->putFileInStorage($uploadedFile);
|
||||
$largestExistingOrder = Attachment::query()->where('uploaded_to', '=', $page_id)->max('order');
|
||||
$largestExistingOrder = Attachment::query()->where('uploaded_to', '=', $pageId)->max('order');
|
||||
|
||||
/** @var Attachment $attachment */
|
||||
$attachment = Attachment::query()->forceCreate([
|
||||
'name' => $attachmentName,
|
||||
'path' => $attachmentPath,
|
||||
'extension' => $uploadedFile->getClientOriginalExtension(),
|
||||
'uploaded_to' => $page_id,
|
||||
'uploaded_to' => $pageId,
|
||||
'created_by' => user()->id,
|
||||
'updated_by' => user()->id,
|
||||
'order' => $largestExistingOrder + 1,
|
||||
@@ -159,18 +159,20 @@ class AttachmentService
|
||||
public function updateFile(Attachment $attachment, array $requestData): Attachment
|
||||
{
|
||||
$attachment->name = $requestData['name'];
|
||||
$link = trim($requestData['link'] ?? '');
|
||||
|
||||
if (isset($requestData['link']) && trim($requestData['link']) !== '') {
|
||||
$attachment->path = $requestData['link'];
|
||||
if (!empty($link)) {
|
||||
if (!$attachment->external) {
|
||||
$this->deleteFileInStorage($attachment);
|
||||
$attachment->external = true;
|
||||
$attachment->extension = '';
|
||||
}
|
||||
$attachment->path = $requestData['link'];
|
||||
}
|
||||
|
||||
$attachment->save();
|
||||
|
||||
return $attachment;
|
||||
return $attachment->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,13 +182,10 @@ class AttachmentService
|
||||
*/
|
||||
public function deleteFile(Attachment $attachment)
|
||||
{
|
||||
if ($attachment->external) {
|
||||
$attachment->delete();
|
||||
|
||||
return;
|
||||
if (!$attachment->external) {
|
||||
$this->deleteFileInStorage($attachment);
|
||||
}
|
||||
|
||||
$this->deleteFileInStorage($attachment);
|
||||
$attachment->delete();
|
||||
}
|
||||
|
||||
@@ -196,7 +195,7 @@ class AttachmentService
|
||||
*/
|
||||
protected function deleteFileInStorage(Attachment $attachment)
|
||||
{
|
||||
$storage = $this->getStorage();
|
||||
$storage = $this->getStorageDisk();
|
||||
$dirPath = $this->adjustPathForStorageDisk(dirname($attachment->path));
|
||||
|
||||
$storage->delete($this->adjustPathForStorageDisk($attachment->path));
|
||||
@@ -214,10 +213,10 @@ class AttachmentService
|
||||
{
|
||||
$attachmentData = file_get_contents($uploadedFile->getRealPath());
|
||||
|
||||
$storage = $this->getStorage();
|
||||
$storage = $this->getStorageDisk();
|
||||
$basePath = 'uploads/files/' . date('Y-m-M') . '/';
|
||||
|
||||
$uploadFileName = Str::random(16) . '.' . $uploadedFile->getClientOriginalExtension();
|
||||
$uploadFileName = Str::random(16) . '-' . $uploadedFile->getClientOriginalExtension();
|
||||
while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) {
|
||||
$uploadFileName = Str::random(3) . $uploadFileName;
|
||||
}
|
||||
|
||||
@@ -11,24 +11,16 @@ use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
class ImageRepo
|
||||
{
|
||||
protected $image;
|
||||
protected $imageService;
|
||||
protected $restrictionService;
|
||||
protected $page;
|
||||
|
||||
/**
|
||||
* ImageRepo constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
Image $image,
|
||||
ImageService $imageService,
|
||||
PermissionService $permissionService,
|
||||
Page $page
|
||||
) {
|
||||
$this->image = $image;
|
||||
public function __construct(ImageService $imageService, PermissionService $permissionService)
|
||||
{
|
||||
$this->imageService = $imageService;
|
||||
$this->restrictionService = $permissionService;
|
||||
$this->page = $page;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,7 +28,7 @@ class ImageRepo
|
||||
*/
|
||||
public function getById($id): Image
|
||||
{
|
||||
return $this->image->findOrFail($id);
|
||||
return Image::query()->findOrFail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,7 +41,7 @@ class ImageRepo
|
||||
$hasMore = count($images) > $pageSize;
|
||||
|
||||
$returnImages = $images->take($pageSize);
|
||||
$returnImages->each(function ($image) {
|
||||
$returnImages->each(function (Image $image) {
|
||||
$this->loadThumbs($image);
|
||||
});
|
||||
|
||||
@@ -71,7 +63,7 @@ class ImageRepo
|
||||
string $search = null,
|
||||
callable $whereClause = null
|
||||
): array {
|
||||
$imageQuery = $this->image->newQuery()->where('type', '=', strtolower($type));
|
||||
$imageQuery = Image::query()->where('type', '=', strtolower($type));
|
||||
|
||||
if ($uploadedTo !== null) {
|
||||
$imageQuery = $imageQuery->where('uploaded_to', '=', $uploadedTo);
|
||||
@@ -102,7 +94,8 @@ class ImageRepo
|
||||
int $uploadedTo = null,
|
||||
string $search = null
|
||||
): array {
|
||||
$contextPage = $this->page->findOrFail($uploadedTo);
|
||||
/** @var Page $contextPage */
|
||||
$contextPage = Page::visible()->findOrFail($uploadedTo);
|
||||
$parentFilter = null;
|
||||
|
||||
if ($filterType === 'book' || $filterType === 'page') {
|
||||
@@ -137,7 +130,7 @@ class ImageRepo
|
||||
*
|
||||
* @throws ImageUploadException
|
||||
*/
|
||||
public function saveNewFromData(string $imageName, string $imageData, string $type, int $uploadedTo = 0)
|
||||
public function saveNewFromData(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
|
||||
{
|
||||
$image = $this->imageService->saveNew($imageName, $imageData, $type, $uploadedTo);
|
||||
$this->loadThumbs($image);
|
||||
@@ -146,13 +139,13 @@ class ImageRepo
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a drawing the the database.
|
||||
* Save a drawing in the database.
|
||||
*
|
||||
* @throws ImageUploadException
|
||||
*/
|
||||
public function saveDrawing(string $base64Uri, int $uploadedTo): Image
|
||||
{
|
||||
$name = 'Drawing-' . strval(user()->id) . '-' . strval(time()) . '.png';
|
||||
$name = 'Drawing-' . user()->id . '-' . time() . '.png';
|
||||
|
||||
return $this->imageService->saveNewFromBase64Uri($base64Uri, $name, 'drawio', $uploadedTo);
|
||||
}
|
||||
@@ -160,7 +153,6 @@ class ImageRepo
|
||||
/**
|
||||
* Update the details of an image via an array of properties.
|
||||
*
|
||||
* @throws ImageUploadException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function updateImageDetails(Image $image, $updateDetails): Image
|
||||
@@ -177,13 +169,11 @@ class ImageRepo
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroyImage(Image $image = null): bool
|
||||
public function destroyImage(Image $image = null): void
|
||||
{
|
||||
if ($image) {
|
||||
$this->imageService->destroy($image);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,9 +181,9 @@ class ImageRepo
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroyByType(string $imageType)
|
||||
public function destroyByType(string $imageType): void
|
||||
{
|
||||
$images = $this->image->where('type', '=', $imageType)->get();
|
||||
$images = Image::query()->where('type', '=', $imageType)->get();
|
||||
foreach ($images as $image) {
|
||||
$this->destroyImage($image);
|
||||
}
|
||||
@@ -201,25 +191,21 @@ class ImageRepo
|
||||
|
||||
/**
|
||||
* Load thumbnails onto an image object.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function loadThumbs(Image $image)
|
||||
public function loadThumbs(Image $image): void
|
||||
{
|
||||
$image->thumbs = [
|
||||
$image->setAttribute('thumbs', [
|
||||
'gallery' => $this->getThumbnail($image, 150, 150, false),
|
||||
'display' => $this->getThumbnail($image, 1680, null, true),
|
||||
];
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the thumbnail for an image.
|
||||
* If $keepRatio is true only the width will be used.
|
||||
* Checks the cache then storage to avoid creating / accessing the filesystem on every check.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function getThumbnail(Image $image, ?int $width = 220, ?int $height = 220, bool $keepRatio = false): ?string
|
||||
protected function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio): ?string
|
||||
{
|
||||
try {
|
||||
return $this->imageService->getThumbnail($image, $width, $height, $keepRatio);
|
||||
|
||||
@@ -11,11 +11,14 @@ use Illuminate\Contracts\Filesystem\FileNotFoundException;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem as Storage;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Intervention\Image\Exception\NotSupportedException;
|
||||
use Intervention\Image\ImageManager;
|
||||
use League\Flysystem\Util;
|
||||
use Psr\SimpleCache\InvalidArgumentException;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class ImageService
|
||||
{
|
||||
@@ -25,6 +28,8 @@ class ImageService
|
||||
protected $image;
|
||||
protected $fileSystem;
|
||||
|
||||
protected static $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
|
||||
/**
|
||||
* ImageService constructor.
|
||||
*/
|
||||
@@ -39,11 +44,20 @@ class ImageService
|
||||
/**
|
||||
* Get the storage that will be used for storing images.
|
||||
*/
|
||||
protected function getStorage(string $imageType = ''): FileSystemInstance
|
||||
protected function getStorageDisk(string $imageType = ''): FileSystemInstance
|
||||
{
|
||||
return $this->fileSystem->disk($this->getStorageDiskName($imageType));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if local secure image storage (Fetched behind authentication)
|
||||
* is currently active in the instance.
|
||||
*/
|
||||
protected function usingSecureImages(): bool
|
||||
{
|
||||
return $this->getStorageDiskName('gallery') === 'local_secure_images';
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the originally provided path to fit any disk-specific requirements.
|
||||
* This also ensures the path is kept to the expected root folders.
|
||||
@@ -126,7 +140,7 @@ class ImageService
|
||||
*/
|
||||
public function saveNew(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
|
||||
{
|
||||
$storage = $this->getStorage($type);
|
||||
$storage = $this->getStorageDisk($type);
|
||||
$secureUploads = setting('app-secure-images');
|
||||
$fileName = $this->cleanImageFileName($imageName);
|
||||
|
||||
@@ -144,7 +158,7 @@ class ImageService
|
||||
try {
|
||||
$this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($fullPath, $type), $imageData);
|
||||
} catch (Exception $e) {
|
||||
\Log::error('Error when attempting image upload:' . $e->getMessage());
|
||||
Log::error('Error when attempting image upload:' . $e->getMessage());
|
||||
|
||||
throw new ImageUploadException(trans('errors.path_not_writable', ['filePath' => $fullPath]));
|
||||
}
|
||||
@@ -219,17 +233,10 @@ class ImageService
|
||||
* If $keepRatio is true only the width will be used.
|
||||
* Checks the cache then storage to avoid creating / accessing the filesystem on every check.
|
||||
*
|
||||
* @param Image $image
|
||||
* @param int $width
|
||||
* @param int $height
|
||||
* @param bool $keepRatio
|
||||
*
|
||||
* @throws Exception
|
||||
* @throws ImageUploadException
|
||||
*
|
||||
* @return string
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false)
|
||||
public function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio = false): string
|
||||
{
|
||||
if ($keepRatio && $this->isGif($image)) {
|
||||
return $this->getPublicUrl($image->path);
|
||||
@@ -243,7 +250,7 @@ class ImageService
|
||||
return $this->getPublicUrl($thumbFilePath);
|
||||
}
|
||||
|
||||
$storage = $this->getStorage($image->type);
|
||||
$storage = $this->getStorageDisk($image->type);
|
||||
if ($storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) {
|
||||
return $this->getPublicUrl($thumbFilePath);
|
||||
}
|
||||
@@ -257,27 +264,16 @@ class ImageService
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize image data.
|
||||
*
|
||||
* @param string $imageData
|
||||
* @param int $width
|
||||
* @param int $height
|
||||
* @param bool $keepRatio
|
||||
* Resize the image of given data to the specified size, and return the new image data.
|
||||
*
|
||||
* @throws ImageUploadException
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function resizeImage(string $imageData, $width = 220, $height = null, bool $keepRatio = true)
|
||||
protected function resizeImage(string $imageData, ?int $width, ?int $height, bool $keepRatio): string
|
||||
{
|
||||
try {
|
||||
$thumb = $this->imageTool->make($imageData);
|
||||
} catch (Exception $e) {
|
||||
if ($e instanceof ErrorException || $e instanceof NotSupportedException) {
|
||||
throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
|
||||
}
|
||||
|
||||
throw $e;
|
||||
} catch (ErrorException|NotSupportedException $e) {
|
||||
throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
|
||||
}
|
||||
|
||||
if ($keepRatio) {
|
||||
@@ -307,7 +303,7 @@ class ImageService
|
||||
*/
|
||||
public function getImageData(Image $image): string
|
||||
{
|
||||
$storage = $this->getStorage();
|
||||
$storage = $this->getStorageDisk();
|
||||
|
||||
return $storage->get($this->adjustPathForStorageDisk($image->path, $image->type));
|
||||
}
|
||||
@@ -330,7 +326,7 @@ class ImageService
|
||||
protected function destroyImagesFromPath(string $path, string $imageType): bool
|
||||
{
|
||||
$path = $this->adjustPathForStorageDisk($path, $imageType);
|
||||
$storage = $this->getStorage($imageType);
|
||||
$storage = $this->getStorageDisk($imageType);
|
||||
|
||||
$imageFolder = dirname($path);
|
||||
$imageFileName = basename($path);
|
||||
@@ -417,7 +413,7 @@ class ImageService
|
||||
}
|
||||
|
||||
$storagePath = $this->adjustPathForStorageDisk($storagePath);
|
||||
$storage = $this->getStorage();
|
||||
$storage = $this->getStorageDisk();
|
||||
$imageData = null;
|
||||
if ($storage->exists($storagePath)) {
|
||||
$imageData = $storage->get($storagePath);
|
||||
@@ -435,6 +431,42 @@ class ImageService
|
||||
return 'data:image/' . $extension . ';base64,' . base64_encode($imageData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given path exists in the local secure image system.
|
||||
* Returns false if local_secure is not in use.
|
||||
*/
|
||||
public function pathExistsInLocalSecure(string $imagePath): bool
|
||||
{
|
||||
$disk = $this->getStorageDisk('gallery');
|
||||
|
||||
// Check local_secure is active
|
||||
return $this->usingSecureImages()
|
||||
// Check the image file exists
|
||||
&& $disk->exists($imagePath)
|
||||
// Check the file is likely an image file
|
||||
&& strpos($disk->getMimetype($imagePath), 'image/') === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* For the given path, if existing, provide a response that will stream the image contents.
|
||||
*/
|
||||
public function streamImageFromStorageResponse(string $imageType, string $path): StreamedResponse
|
||||
{
|
||||
$disk = $this->getStorageDisk($imageType);
|
||||
|
||||
return $disk->response($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given image extension is supported by BookStack.
|
||||
* The extension must not be altered in this function. This check should provide a guarantee
|
||||
* that the provided extension is safe to use for the image to be saved.
|
||||
*/
|
||||
public static function isExtensionSupported(string $extension): bool
|
||||
{
|
||||
return in_array($extension, static::$supportedExtensions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a storage path for the given image URL.
|
||||
* Ensures the path will start with "uploads/images".
|
||||
@@ -476,7 +508,7 @@ class ImageService
|
||||
*/
|
||||
private function getPublicUrl(string $filePath): string
|
||||
{
|
||||
if ($this->storageUrl === null) {
|
||||
if (is_null($this->storageUrl)) {
|
||||
$storageUrl = config('filesystems.url');
|
||||
|
||||
// Get the standard public s3 url if s3 is set as storage type
|
||||
@@ -490,6 +522,7 @@ class ImageService
|
||||
$storageUrl = 'https://s3-' . $storageDetails['region'] . '.amazonaws.com/' . $storageDetails['bucket'];
|
||||
}
|
||||
}
|
||||
|
||||
$this->storageUrl = $storageUrl;
|
||||
}
|
||||
|
||||
|
||||
63
app/Util/WebSafeMimeSniffer.php
Normal file
63
app/Util/WebSafeMimeSniffer.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Util;
|
||||
|
||||
use finfo;
|
||||
|
||||
/**
|
||||
* Helper class to sniff out the mime-type of content resulting in
|
||||
* a mime-type that's relatively safe to serve to a browser.
|
||||
*/
|
||||
class WebSafeMimeSniffer
|
||||
{
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
protected $safeMimes = [
|
||||
'application/json',
|
||||
'application/octet-stream',
|
||||
'application/pdf',
|
||||
'image/bmp',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/avif',
|
||||
'image/heic',
|
||||
'text/css',
|
||||
'text/csv',
|
||||
'text/javascript',
|
||||
'text/json',
|
||||
'text/plain',
|
||||
'video/x-msvideo',
|
||||
'video/mp4',
|
||||
'video/mpeg',
|
||||
'video/ogg',
|
||||
'video/webm',
|
||||
'video/vp9',
|
||||
'video/h264',
|
||||
'video/av1',
|
||||
];
|
||||
|
||||
/**
|
||||
* Sniff the mime-type from the given file content while running the result
|
||||
* through an allow-list to ensure a web-safe result.
|
||||
* Takes the content as a reference since the value may be quite large.
|
||||
*/
|
||||
public function sniff(string &$content): string
|
||||
{
|
||||
$fInfo = new finfo(FILEINFO_MIME_TYPE);
|
||||
$mime = $fInfo->buffer($content) ?: 'application/octet-stream';
|
||||
|
||||
if (in_array($mime, $this->safeMimes)) {
|
||||
return $mime;
|
||||
}
|
||||
|
||||
[$category] = explode('/', $mime, 2);
|
||||
if ($category === 'text') {
|
||||
return 'text/plain';
|
||||
}
|
||||
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
@@ -17,21 +17,23 @@
|
||||
"barryvdh/laravel-dompdf": "^0.9.0",
|
||||
"barryvdh/laravel-snappy": "^0.4.8",
|
||||
"doctrine/dbal": "^2.12.1",
|
||||
"facade/ignition": "^1.16.4",
|
||||
"fideloper/proxy": "^4.4.1",
|
||||
"filp/whoops": "^2.14",
|
||||
"intervention/image": "^2.5.1",
|
||||
"laravel/framework": "^6.20.33",
|
||||
"laravel/socialite": "^5.1",
|
||||
"league/commonmark": "^1.5",
|
||||
"league/flysystem-aws-s3-v3": "^1.0.29",
|
||||
"league/html-to-markdown": "^5.0.0",
|
||||
"league/oauth2-client": "^2.6",
|
||||
"nunomaduro/collision": "^3.1",
|
||||
"onelogin/php-saml": "^4.0",
|
||||
"phpseclib/phpseclib": "~3.0",
|
||||
"pragmarx/google2fa": "^8.0",
|
||||
"predis/predis": "^1.1.6",
|
||||
"socialiteproviders/discord": "^4.1",
|
||||
"socialiteproviders/gitlab": "^4.1",
|
||||
"socialiteproviders/microsoft-azure": "^4.1",
|
||||
"socialiteproviders/microsoft-azure": "^5.0.1",
|
||||
"socialiteproviders/okta": "^4.1",
|
||||
"socialiteproviders/slack": "^4.1",
|
||||
"socialiteproviders/twitch": "^5.3",
|
||||
|
||||
1121
composer.lock
generated
1121
composer.lock
generated
File diff suppressed because it is too large
Load Diff
5
dev/api/requests/attachments-create.json
Normal file
5
dev/api/requests/attachments-create.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "My uploaded attachment",
|
||||
"uploaded_to": 8,
|
||||
"link": "https://link.example.com"
|
||||
}
|
||||
5
dev/api/requests/attachments-update.json
Normal file
5
dev/api/requests/attachments-update.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "My updated attachment",
|
||||
"uploaded_to": 4,
|
||||
"link": "https://link.example.com/updated"
|
||||
}
|
||||
12
dev/api/responses/attachments-create.json
Normal file
12
dev/api/responses/attachments-create.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": 5,
|
||||
"name": "My uploaded attachment",
|
||||
"extension": "",
|
||||
"uploaded_to": 8,
|
||||
"external": true,
|
||||
"order": 2,
|
||||
"created_by": 1,
|
||||
"updated_by": 1,
|
||||
"created_at": "2021-10-20 06:35:46",
|
||||
"updated_at": "2021-10-20 06:35:46"
|
||||
}
|
||||
29
dev/api/responses/attachments-list.json
Normal file
29
dev/api/responses/attachments-list.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 3,
|
||||
"name": "datasheet.pdf",
|
||||
"extension": "pdf",
|
||||
"uploaded_to": 8,
|
||||
"external": false,
|
||||
"order": 1,
|
||||
"created_at": "2021-10-11 06:18:49",
|
||||
"updated_at": "2021-10-20 06:31:10",
|
||||
"created_by": 1,
|
||||
"updated_by": 1
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Cat reference",
|
||||
"extension": "",
|
||||
"uploaded_to": 9,
|
||||
"external": true,
|
||||
"order": 1,
|
||||
"created_at": "2021-10-20 06:30:11",
|
||||
"updated_at": "2021-10-20 06:30:11",
|
||||
"created_by": 1,
|
||||
"updated_by": 1
|
||||
}
|
||||
],
|
||||
"total": 2
|
||||
}
|
||||
25
dev/api/responses/attachments-read.json
Normal file
25
dev/api/responses/attachments-read.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"id": 5,
|
||||
"name": "My link attachment",
|
||||
"extension": "",
|
||||
"uploaded_to": 4,
|
||||
"external": true,
|
||||
"order": 2,
|
||||
"created_by": {
|
||||
"id": 1,
|
||||
"name": "Admin",
|
||||
"slug": "admin"
|
||||
},
|
||||
"updated_by": {
|
||||
"id": 1,
|
||||
"name": "Admin",
|
||||
"slug": "admin"
|
||||
},
|
||||
"created_at": "2021-10-20 06:35:46",
|
||||
"updated_at": "2021-10-20 06:37:11",
|
||||
"links": {
|
||||
"html": "<a target=\"_blank\" href=\"https://bookstack.local/attachments/5\">My updated attachment</a>",
|
||||
"markdown": "[My updated attachment](https://bookstack.local/attachments/5)"
|
||||
},
|
||||
"content": "https://link.example.com/updated"
|
||||
}
|
||||
12
dev/api/responses/attachments-update.json
Normal file
12
dev/api/responses/attachments-update.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": 5,
|
||||
"name": "My updated attachment",
|
||||
"extension": "",
|
||||
"uploaded_to": 4,
|
||||
"external": true,
|
||||
"order": 2,
|
||||
"created_by": 1,
|
||||
"updated_by": 1,
|
||||
"created_at": "2021-10-20 06:35:46",
|
||||
"updated_at": "2021-10-20 06:37:11"
|
||||
}
|
||||
2
public/dist/export-styles.css
vendored
2
public/dist/export-styles.css
vendored
File diff suppressed because one or more lines are too long
2
public/dist/styles.css
vendored
2
public/dist/styles.css
vendored
File diff suppressed because one or more lines are too long
41
readme.md
41
readme.md
@@ -14,17 +14,37 @@ A platform for storing and organising information and documentation. Details for
|
||||
* [Documentation](https://www.bookstackapp.com/docs)
|
||||
* [Demo Instance](https://demo.bookstackapp.com)
|
||||
* [Admin Login](https://demo.bookstackapp.com/login?email=admin@example.com&password=password)
|
||||
* [Screenshots](https://www.bookstackapp.com/#screenshots)
|
||||
* [BookStack Blog](https://www.bookstackapp.com/blog)
|
||||
* [Issue List](https://github.com/BookStackApp/BookStack/issues)
|
||||
* [Discord Chat](https://discord.gg/ztkBqR2)
|
||||
|
||||
## 📚 Project Definition
|
||||
|
||||
BookStack is an opinionated wiki system that provides a pleasant and simple out of the box experience. New users to an instance should find the experience intuitive and only basic word-processing skills should be required to get involved in creating content on BookStack. The platform should provide advanced power features to those that desire it but they should not interfere with the core simple user experience.
|
||||
BookStack is an opinionated wiki system that provides a pleasant and simple out-of-the-box experience. New users to an instance should find the experience intuitive and only basic word-processing skills should be required to get involved in creating content on BookStack. The platform should provide advanced power features to those that desire it but they should not interfere with the core simple user experience.
|
||||
|
||||
BookStack is not designed as an extensible platform to be used for purposes that differ to the statement above.
|
||||
|
||||
In regards to development philosophy, BookStack has a relaxed, open & positive approach. At the end of the day this is free software developed and maintained by people donating their own free time.
|
||||
In regard to development philosophy, BookStack has a relaxed, open & positive approach. At the end of the day this is free software developed and maintained by people donating their own free time.
|
||||
|
||||
## 🌟 Project Sponsors
|
||||
|
||||
Shown below are our bronze, silver and gold project sponsors.
|
||||
Big thanks to these companies for supporting the project.
|
||||
Note: Listed services are not tested, vetted nor supported by the official BookStack project in any manner.
|
||||
[View all sponsors](https://github.com/sponsors/ssddanbrown).
|
||||
|
||||
#### Bronze Sponsors
|
||||
|
||||
<table><tbody><tr>
|
||||
<td><a href="https://www.diagrams.net/" target="_blank">
|
||||
<img width="280" src="https://media.githubusercontent.com/media/BookStackApp/website/master/static/images/sponsors/diagramsnet.png" alt="Diagrams.net logo">
|
||||
</a></td>
|
||||
|
||||
<td><a href="https://www.stellarhosted.com/bookstack/" target="_blank">
|
||||
<img width="280" src="https://media.githubusercontent.com/media/BookStackApp/website/master/static/images/sponsors/stellarhosted.png" alt="Stellar Hosted Logo">
|
||||
</a></td>
|
||||
</tr></tbody></table>
|
||||
|
||||
## 🛣️ Road Map
|
||||
|
||||
@@ -41,17 +61,23 @@ Below is a high-level road map view for BookStack to provide a sense of directio
|
||||
|
||||
## 🚀 Release Versioning & Process
|
||||
|
||||
BookStack releases are each assigned a version number, such as "v0.25.2", in the format `v<phase>.<feature>.<patch>`. A change only in the `patch` number indicates a fairly minor release that mainly contains fixes and therefore is very unlikely to cause breakages upon update. A change in the `feature` number indicates a release which will generally bring new features in addition to fixes and enhancements. These releases have a small chance of introducing breaking changes upon update so it's worth checking for any notes in the [update guide](https://www.bookstackapp.com/docs/admin/updates/). A change in the `phase` indicates a much large change in BookStack that will likely incur breakages requiring manual intervention.
|
||||
BookStack releases are each assigned a date-based version number in the format `v<year>.<month>[.<optional_patch_number>]`. For example:
|
||||
|
||||
- `v20.12` - New feature released launched during December 2020.
|
||||
- `v21.06.2` - Second patch release upon the June 2021 feature release.
|
||||
|
||||
Patch releases are generally fairly minor, primarily intended for fixes and therefore is fairly unlikely to cause breakages upon update.
|
||||
Feature releases are generally larger, bringing new features in addition to fixes and enhancements. These releases have a greater chance of introducing breaking changes upon update, so it's worth checking for any notes in the [update guide](https://www.bookstackapp.com/docs/admin/updates/).
|
||||
|
||||
Each BookStack release will have a [milestone](https://github.com/BookStackApp/BookStack/milestones) created with issues & pull requests assigned to it to define what will be in that release. Milestones are built up then worked through until complete at which point, after some testing and documentation updates, the release will be deployed.
|
||||
|
||||
For feature releases, and some patch releases, the release will be accompanied by a post on the [BookStack blog](https://www.bookstackapp.com/blog/) which will provide additional detail on features, changes & updates otherwise the [GitHub release page](https://github.com/BookStackApp/BookStack/releases) will show a list of changes. You can sign up to be alerted to new BookStack blogs posts (once per week maximum) [at this link](https://updates.bookstackapp.com/signup/bookstack-news-and-updates).
|
||||
Feature releases, and some patch releases, will be accompanied by a post on the [BookStack blog](https://www.bookstackapp.com/blog/) which will provide additional detail on features, changes & updates otherwise the [GitHub release page](https://github.com/BookStackApp/BookStack/releases) will show a list of changes. You can sign up to be alerted to new BookStack blogs posts (once per week maximum) [at this link](https://updates.bookstackapp.com/signup/bookstack-news-and-updates).
|
||||
|
||||
## 🛠️ Development & Testing
|
||||
|
||||
All development on BookStack is currently done on the master branch. When it's time for a release the master branch is merged into release with built & minified CSS & JS then tagged at its version. Here are the current development requirements:
|
||||
|
||||
* [Node.js](https://nodejs.org/en/) v12.0+
|
||||
* [Node.js](https://nodejs.org/en/) v14.0+
|
||||
|
||||
This project uses SASS for CSS development and this is built, along with the JavaScript, using a range of npm scripts. The below npm commands can be used to install the dependencies & run the build tasks:
|
||||
|
||||
@@ -150,7 +176,7 @@ Security information for administering a BookStack instance can be found on the
|
||||
|
||||
If you'd like to be notified of new potential security concerns you can [sign-up to the BookStack security mailing list](https://updates.bookstackapp.com/signup/bookstack-security-updates).
|
||||
|
||||
If you would like to report a security concern in a more confidential manner than via a GitHub issue, You can directly email the lead maintainer [ssddanbrown](https://github.com/ssddanbrown). You will need to login to be able to see the email address on the [GitHub profile page](https://github.com/ssddanbrown). Alternatively you can send a DM via twitter to [@ssddanbrown](https://twitter.com/ssddanbrown).
|
||||
If you would like to report a security concern, details of doing so can [can be found here](https://github.com/BookStackApp/BookStack/blob/master/.github/SECURITY.md).
|
||||
|
||||
## ♿ Accessibility
|
||||
|
||||
@@ -192,4 +218,5 @@ These are the great open-source projects used to help build BookStack:
|
||||
* [League/Flysystem](https://flysystem.thephpleague.com)
|
||||
* [StyleCI](https://styleci.io/)
|
||||
* [pragmarx/google2fa](https://github.com/antonioribeiro/google2fa)
|
||||
* [Bacon/BaconQrCode](https://github.com/Bacon/BaconQrCode)
|
||||
* [Bacon/BaconQrCode](https://github.com/Bacon/BaconQrCode)
|
||||
* [phpseclib](https://github.com/phpseclib/phpseclib)
|
||||
4
resources/icons/oidc.svg
Normal file
4
resources/icons/oidc.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 0h24v24H0z" fill="none"/>
|
||||
<path d="M12.65 10C11.83 7.67 9.61 6 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6c2.61 0 4.83-1.67 5.65-4H17v4h4v-4h2v-4H12.65zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 282 B |
@@ -23,6 +23,10 @@ return [
|
||||
'saml_no_email_address' => 'تعذر العثور على عنوان بريد إلكتروني، لهذا المستخدم، في البيانات المقدمة من نظام المصادقة الخارجي',
|
||||
'saml_invalid_response_id' => 'لم يتم التعرف على الطلب من نظام التوثيق الخارجي من خلال عملية تبدأ بهذا التطبيق. العودة بعد تسجيل الدخول يمكن أن يسبب هذه المشكلة.',
|
||||
'saml_fail_authed' => 'تسجيل الدخول باستخدام :system فشل، النظام لم يوفر التفويض الناجح',
|
||||
'oidc_already_logged_in' => 'Already logged in',
|
||||
'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
|
||||
'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system',
|
||||
'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization',
|
||||
'social_no_action_defined' => 'لم يتم تعريف أي إجراء',
|
||||
'social_login_bad_response' => "حصل خطأ خلال تسجيل الدخول باستخدام :socialAccount \n:error",
|
||||
'social_account_in_use' => 'حساب :socialAccount قيد الاستخدام حالياً, الرجاء محاولة الدخول باستخدام خيار :socialAccount.',
|
||||
|
||||
@@ -248,6 +248,7 @@ return [
|
||||
'de_informal' => 'Deutsch (Du)',
|
||||
'es' => 'Español',
|
||||
'es_AR' => 'Español Argentina',
|
||||
'et' => 'Eesti keel',
|
||||
'fr' => 'Français',
|
||||
'he' => 'עברית',
|
||||
'hr' => 'Hrvatski',
|
||||
|
||||
@@ -23,6 +23,10 @@ return [
|
||||
'saml_no_email_address' => 'Не успяхме да намерим емейл адрес, за този потребител, от информацията предоставена от външната система',
|
||||
'saml_invalid_response_id' => 'Заявката от външната система не е разпознат от процеса започнат от това приложение. Връщането назад след влизане може да породи този проблем.',
|
||||
'saml_fail_authed' => 'Влизането чрез :system не беше успешно, системата не успя да оторизира потребителя',
|
||||
'oidc_already_logged_in' => 'Already logged in',
|
||||
'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
|
||||
'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system',
|
||||
'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization',
|
||||
'social_no_action_defined' => 'Действието не беше дефинирано',
|
||||
'social_login_bad_response' => "Възникна грешка по време на :socialAccount login: \n:error",
|
||||
'social_account_in_use' => 'Този :socialAccount вече е използван. Опитайте се да влезете чрез опцията за :socialAccount.',
|
||||
|
||||
@@ -248,6 +248,7 @@ return [
|
||||
'de_informal' => 'Deutsch (Du)',
|
||||
'es' => 'Español',
|
||||
'es_AR' => 'Español Argentina',
|
||||
'et' => 'Eesti keel',
|
||||
'fr' => 'Français',
|
||||
'he' => 'עברית',
|
||||
'hr' => 'Hrvatski',
|
||||
|
||||
@@ -23,6 +23,10 @@ return [
|
||||
'saml_no_email_address' => 'E-mail adresa za ovog korisnika nije nađena u podacima dobijenim od eksternog autentifikacijskog sistema',
|
||||
'saml_invalid_response_id' => 'Proces, koji je pokrenula ova aplikacija, nije prepoznao zahtjev od eksternog sistema za autentifikaciju. Navigacija nazad nakon prijave može uzrokovati ovaj problem.',
|
||||
'saml_fail_authed' => 'Prijava koristeći :system nije uspjela, sistem nije obezbijedio uspješnu autorizaciju',
|
||||
'oidc_already_logged_in' => 'Already logged in',
|
||||
'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
|
||||
'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system',
|
||||
'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization',
|
||||
'social_no_action_defined' => 'Nema definisane akcije',
|
||||
'social_login_bad_response' => "Došlo je do greške prilikom prijave preko :socialAccount :\n:error",
|
||||
'social_account_in_use' => 'Ovaj :socialAccount račun se već koristi, pokušajte se prijaviti putem :socialAccount opcije.',
|
||||
|
||||
@@ -248,6 +248,7 @@ return [
|
||||
'de_informal' => 'Deutsch (Du)',
|
||||
'es' => 'Español',
|
||||
'es_AR' => 'Español Argentina',
|
||||
'et' => 'Eesti keel',
|
||||
'fr' => 'Français',
|
||||
'he' => 'עברית',
|
||||
'hr' => 'Hrvatski',
|
||||
|
||||
@@ -23,6 +23,10 @@ return [
|
||||
'saml_no_email_address' => 'No s\'ha pogut trobar cap adreça electrònica, per a aquest usuari, en les dades proporcionades pel sistema d\'autenticació extern',
|
||||
'saml_invalid_response_id' => 'La petició del sistema d\'autenticació extern no és reconeguda per un procés iniciat per aquesta aplicació. Aquest problema podria ser causat per navegar endarrere després d\'iniciar la sessió.',
|
||||
'saml_fail_authed' => 'L\'inici de sessió fent servir :system ha fallat, el sistema no ha proporcionat una autorització satisfactòria',
|
||||
'oidc_already_logged_in' => 'Already logged in',
|
||||
'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
|
||||
'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system',
|
||||
'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization',
|
||||
'social_no_action_defined' => 'No hi ha cap acció definida',
|
||||
'social_login_bad_response' => "S'ha rebut un error mentre s'iniciava la sessió amb :socialAccount: \n:error",
|
||||
'social_account_in_use' => 'Aquest compte de :socialAccount ja està en ús, proveu d\'iniciar la sessió mitjançant l\'opció de :socialAccount.',
|
||||
|
||||
@@ -248,6 +248,7 @@ return [
|
||||
'de_informal' => 'Deutsch (Du)',
|
||||
'es' => 'Español',
|
||||
'es_AR' => 'Español Argentina',
|
||||
'et' => 'Eesti keel',
|
||||
'fr' => 'Français',
|
||||
'he' => 'עברית',
|
||||
'hr' => 'Hrvatski',
|
||||
|
||||
@@ -23,6 +23,10 @@ return [
|
||||
'saml_no_email_address' => 'Nelze najít e-mailovou adresu pro tohoto uživatele v datech poskytnutých externím přihlašovacím systémem',
|
||||
'saml_invalid_response_id' => 'Požadavek z externího ověřovacího systému nebyl rozpoznám procesem, který tato aplikace spustila. Tento problém může způsobit stisknutí tlačítka Zpět po přihlášení.',
|
||||
'saml_fail_authed' => 'Přihlášení pomocí :system selhalo, systém neposkytl úspěšnou autorizaci',
|
||||
'oidc_already_logged_in' => 'Already logged in',
|
||||
'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
|
||||
'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system',
|
||||
'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization',
|
||||
'social_no_action_defined' => 'Nebyla zvolena žádá akce',
|
||||
'social_login_bad_response' => "Nastala chyba během přihlašování přes :socialAccount \n:error",
|
||||
'social_account_in_use' => 'Tento účet na :socialAccount se již používá. Pokuste se s ním přihlásit volbou Přihlásit přes :socialAccount.',
|
||||
|
||||
@@ -248,6 +248,7 @@ return [
|
||||
'de_informal' => 'Deutsch (Du)',
|
||||
'es' => 'Español',
|
||||
'es_AR' => 'Español Argentina',
|
||||
'et' => 'Eesti keel',
|
||||
'fr' => 'Français',
|
||||
'he' => 'עברית',
|
||||
'hr' => 'Hrvatski',
|
||||
|
||||
@@ -23,6 +23,10 @@ return [
|
||||
'saml_no_email_address' => 'Kunne ikke finde en e-mail-adresse for denne bruger i de data, der leveres af det eksterne godkendelsessystem',
|
||||
'saml_invalid_response_id' => 'Anmodningen fra det eksterne godkendelsessystem genkendes ikke af en proces, der er startet af denne applikation. Navigering tilbage efter et login kan forårsage dette problem.',
|
||||
'saml_fail_authed' => 'Login ved hjælp af :system failed, systemet har ikke givet tilladelse',
|
||||
'oidc_already_logged_in' => 'Already logged in',
|
||||
'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
|
||||
'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system',
|
||||
'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization',
|
||||
'social_no_action_defined' => 'Ingen handling er defineret',
|
||||
'social_login_bad_response' => "Der opstod en fejl under :socialAccount login:\n:error",
|
||||
'social_account_in_use' => 'Denne :socialAccount konto er allerede i brug, prøv at logge ind med :socialAccount loginmetoden.',
|
||||
|
||||
@@ -248,6 +248,7 @@ return [
|
||||
'de_informal' => 'Deutsch (Du)',
|
||||
'es' => 'Español',
|
||||
'es_AR' => 'Español Argentina',
|
||||
'et' => 'Eesti keel',
|
||||
'fr' => 'Français',
|
||||
'he' => 'Hebraisk',
|
||||
'hr' => 'Hrvatski',
|
||||
|
||||
@@ -23,6 +23,10 @@ return [
|
||||
'saml_no_email_address' => 'Es konnte keine E-Mail-Adresse für diesen Benutzer in den vom externen Authentifizierungssystem zur Verfügung gestellten Daten gefunden werden',
|
||||
'saml_invalid_response_id' => 'Die Anfrage vom externen Authentifizierungssystem wird von einem von dieser Anwendung gestarteten Prozess nicht erkannt. Das Zurückgehen nach einem Login könnte dieses Problem verursachen.',
|
||||
'saml_fail_authed' => 'Anmeldung mit :system fehlgeschlagen, System konnte keine erfolgreiche Autorisierung bereitstellen',
|
||||
'oidc_already_logged_in' => 'Already logged in',
|
||||
'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
|
||||
'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system',
|
||||
'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization',
|
||||
'social_no_action_defined' => 'Es ist keine Aktion definiert',
|
||||
'social_login_bad_response' => "Fehler bei der :socialAccount-Anmeldung: \n:error",
|
||||
'social_account_in_use' => 'Dieses :socialAccount-Konto wird bereits verwendet. Bitte melden Sie sich mit dem :socialAccount-Konto an.',
|
||||
|
||||
@@ -251,6 +251,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung
|
||||
'de_informal' => 'Deutsch (Du)',
|
||||
'es' => 'Español',
|
||||
'es_AR' => 'Español Argentina',
|
||||
'et' => 'Estnisch',
|
||||
'fr' => 'Français',
|
||||
'he' => 'Hebräisch',
|
||||
'hr' => 'Hrvatski',
|
||||
|
||||
@@ -48,8 +48,8 @@ return [
|
||||
'favourite_remove_notification' => '":name" wurde aus Ihren Favoriten entfernt',
|
||||
|
||||
// MFA
|
||||
'mfa_setup_method_notification' => 'Multi-factor method successfully configured',
|
||||
'mfa_remove_method_notification' => 'Multi-factor method successfully removed',
|
||||
'mfa_setup_method_notification' => 'Multi-Faktor-Methode erfolgreich konfiguriert',
|
||||
'mfa_remove_method_notification' => 'Multi-Faktor-Methode erfolgreich entfernt',
|
||||
|
||||
// Other
|
||||
'commented_on' => 'kommentiert',
|
||||
|
||||
@@ -76,37 +76,37 @@ return [
|
||||
'user_invite_success' => 'Das Passwort wurde gesetzt, du hast nun Zugriff auf :appName!',
|
||||
|
||||
// Multi-factor Authentication
|
||||
'mfa_setup' => 'Setup Multi-Factor Authentication',
|
||||
'mfa_setup_desc' => 'Setup multi-factor authentication as an extra layer of security for your user account.',
|
||||
'mfa_setup_configured' => 'Already configured',
|
||||
'mfa_setup_reconfigure' => 'Reconfigure',
|
||||
'mfa_setup_remove_confirmation' => 'Are you sure you want to remove this multi-factor authentication method?',
|
||||
'mfa_setup_action' => 'Setup',
|
||||
'mfa_backup_codes_usage_limit_warning' => 'You have less than 5 backup codes remaining, Please generate and store a new set before you run out of codes to prevent being locked out of your account.',
|
||||
'mfa_setup' => 'Multi-Faktor-Authentifizierung einrichten',
|
||||
'mfa_setup_desc' => 'Richten Sie Multi-Faktor-Authentifizierung als zusätzliche Sicherheitsstufe für Ihr Benutzerkonto ein.',
|
||||
'mfa_setup_configured' => 'Bereits konfiguriert',
|
||||
'mfa_setup_reconfigure' => 'Umkonfigurieren',
|
||||
'mfa_setup_remove_confirmation' => 'Sind Sie sicher, dass Sie diese Multi-Faktor-Authentifizierungsmethode entfernen möchten?',
|
||||
'mfa_setup_action' => 'Einrichtung',
|
||||
'mfa_backup_codes_usage_limit_warning' => 'Sie haben weniger als 5 Backup-Codes übrig, Bitte erstellen und speichern Sie ein neues Set bevor Sie keine Codes mehr haben, um zu verhindern, dass Sie von Ihrem Konto gesperrt werden.',
|
||||
'mfa_option_totp_title' => 'Mobile App',
|
||||
'mfa_option_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
|
||||
'mfa_option_backup_codes_title' => 'Backup Codes',
|
||||
'mfa_option_backup_codes_desc' => 'Securely store a set of one-time-use backup codes which you can enter to verify your identity.',
|
||||
'mfa_gen_confirm_and_enable' => 'Confirm and Enable',
|
||||
'mfa_gen_backup_codes_title' => 'Backup Codes Setup',
|
||||
'mfa_gen_backup_codes_desc' => 'Store the below list of codes in a safe place. When accessing the system you\'ll be able to use one of the codes as a second authentication mechanism.',
|
||||
'mfa_option_totp_desc' => 'Um Mehrfach-Faktor-Authentifizierung nutzen zu können, benötigen Sie eine mobile Anwendung, die TOTP unterstützt, wie Google Authenticator, Authy oder Microsoft Authenticator.',
|
||||
'mfa_option_backup_codes_title' => 'Backup Code',
|
||||
'mfa_option_backup_codes_desc' => 'Speichern Sie sicher eine Reihe von einmaligen Backup-Codes, die Sie eingeben können, um Ihre Identität zu überprüfen.',
|
||||
'mfa_gen_confirm_and_enable' => 'Bestätigen und aktivieren',
|
||||
'mfa_gen_backup_codes_title' => 'Backup-Codes einrichten',
|
||||
'mfa_gen_backup_codes_desc' => 'Speichern Sie die folgende Liste der Codes an einem sicheren Ort. Wenn Sie auf das System zugreifen, können Sie einen der Codes als zweiten Authentifizierungsmechanismus verwenden.',
|
||||
'mfa_gen_backup_codes_download' => 'Download Codes',
|
||||
'mfa_gen_backup_codes_usage_warning' => 'Each code can only be used once',
|
||||
'mfa_gen_totp_title' => 'Mobile App Setup',
|
||||
'mfa_gen_totp_desc' => 'To use multi-factor authentication you\'ll need a mobile application that supports TOTP such as Google Authenticator, Authy or Microsoft Authenticator.',
|
||||
'mfa_gen_totp_scan' => 'Scan the QR code below using your preferred authentication app to get started.',
|
||||
'mfa_gen_totp_verify_setup' => 'Verify Setup',
|
||||
'mfa_gen_totp_verify_setup_desc' => 'Verify that all is working by entering a code, generated within your authentication app, in the input box below:',
|
||||
'mfa_gen_totp_provide_code_here' => 'Provide your app generated code here',
|
||||
'mfa_verify_access' => 'Verify Access',
|
||||
'mfa_verify_access_desc' => 'Your user account requires you to confirm your identity via an additional level of verification before you\'re granted access. Verify using one of your configured methods to continue.',
|
||||
'mfa_verify_no_methods' => 'No Methods Configured',
|
||||
'mfa_verify_no_methods_desc' => 'No multi-factor authentication methods could be found for your account. You\'ll need to set up at least one method before you gain access.',
|
||||
'mfa_verify_use_totp' => 'Verify using a mobile app',
|
||||
'mfa_verify_use_backup_codes' => 'Verify using a backup code',
|
||||
'mfa_verify_backup_code' => 'Backup Code',
|
||||
'mfa_verify_backup_code_desc' => 'Enter one of your remaining backup codes below:',
|
||||
'mfa_verify_backup_code_enter_here' => 'Enter backup code here',
|
||||
'mfa_verify_totp_desc' => 'Enter the code, generated using your mobile app, below:',
|
||||
'mfa_setup_login_notification' => 'Multi-factor method configured, Please now login again using the configured method.',
|
||||
'mfa_gen_backup_codes_usage_warning' => 'Jeder Code kann nur einmal verwendet werden',
|
||||
'mfa_gen_totp_title' => 'Mobile App einrichten',
|
||||
'mfa_gen_totp_desc' => 'Um Mehrfach-Faktor-Authentifizierung nutzen zu können, benötigen Sie eine mobile Anwendung, die TOTP unterstützt, wie Google Authenticator, Authy oder Microsoft Authenticator.',
|
||||
'mfa_gen_totp_scan' => 'Scannen Sie den QR-Code unten mit Ihrer bevorzugten Authentifizierungs-App, um loszulegen.',
|
||||
'mfa_gen_totp_verify_setup' => 'Setup überprüfen',
|
||||
'mfa_gen_totp_verify_setup_desc' => 'Überprüfen Sie, dass alles funktioniert, indem Sie einen Code in Ihrer Authentifizierungs-App in das Eingabefeld unten eingeben:',
|
||||
'mfa_gen_totp_provide_code_here' => 'Geben Sie hier Ihre App generierten Code ein',
|
||||
'mfa_verify_access' => 'Zugriff überprüfen',
|
||||
'mfa_verify_access_desc' => 'Ihr Benutzerkonto erfordert, dass Sie Ihre Identität über eine zusätzliche Verifikationsebene bestätigen, bevor Sie den Zugriff gewähren. Überprüfen Sie mit einer Ihrer konfigurierten Methoden, um fortzufahren.',
|
||||
'mfa_verify_no_methods' => 'Keine Methoden konfiguriert',
|
||||
'mfa_verify_no_methods_desc' => 'Es konnten keine Mehrfach-Faktor-Authentifizierungsmethoden für Ihr Konto gefunden werden. Sie müssen mindestens eine Methode einrichten, bevor Sie Zugriff erhalten.',
|
||||
'mfa_verify_use_totp' => 'Mit einer mobilen App verifizieren',
|
||||
'mfa_verify_use_backup_codes' => 'Mit einem Backup-Code überprüfen',
|
||||
'mfa_verify_backup_code' => 'Backup-Code',
|
||||
'mfa_verify_backup_code_desc' => 'Geben Sie einen Ihrer verbleibenden Backup-Codes unten ein:',
|
||||
'mfa_verify_backup_code_enter_here' => 'Backup-Code hier eingeben',
|
||||
'mfa_verify_totp_desc' => 'Geben Sie den Code ein, der mit Ihrer mobilen App generiert wurde:',
|
||||
'mfa_setup_login_notification' => 'Multi-Faktor-Methode konfiguriert. Bitte melden Sie sich jetzt erneut mit der konfigurierten Methode an.',
|
||||
];
|
||||
@@ -23,6 +23,10 @@ return [
|
||||
'saml_no_email_address' => 'Es konnte keine E-Mail-Adresse für diesen Benutzer in den vom externen Authentifizierungssystem zur Verfügung gestellten Daten gefunden werden',
|
||||
'saml_invalid_response_id' => 'Die Anfrage vom externen Authentifizierungssystem wird von einem von dieser Anwendung gestarteten Prozess nicht erkannt. Das Zurückgehen nach einem Login könnte dieses Problem verursachen.',
|
||||
'saml_fail_authed' => 'Anmeldung mit :system fehlgeschlagen, System konnte keine erfolgreiche Autorisierung bereitstellen',
|
||||
'oidc_already_logged_in' => 'Already logged in',
|
||||
'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
|
||||
'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system',
|
||||
'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization',
|
||||
'social_no_action_defined' => 'Es ist keine Aktion definiert',
|
||||
'social_login_bad_response' => "Fehler bei :socialAccount Login: \n:error",
|
||||
'social_account_in_use' => 'Dieses :socialAccount-Konto wird bereits verwendet. Bitte melde dich mit dem :socialAccount-Konto an.',
|
||||
@@ -84,7 +88,7 @@ return [
|
||||
'sorry_page_not_found' => 'Entschuldigung. Die Seite, die Du angefordert hast, wurde nicht gefunden.',
|
||||
'sorry_page_not_found_permission_warning' => 'Wenn du erwartet hast, dass diese Seite existiert, hast du möglicherweise nicht die Berechtigung, sie anzuzeigen.',
|
||||
'image_not_found' => 'Bild nicht gefunden',
|
||||
'image_not_found_subtitle' => 'Entschuldigung. Das Bild, die Sie angefordert haben, wurde nicht gefunden.',
|
||||
'image_not_found_subtitle' => 'Entschuldigung. Das angeforderte Bild wurde nicht gefunden.',
|
||||
'image_not_found_details' => 'Wenn Sie erwartet haben, dass dieses Bild existiert, könnte es gelöscht worden sein.',
|
||||
'return_home' => 'Zurück zur Startseite',
|
||||
'error_occurred' => 'Es ist ein Fehler aufgetreten',
|
||||
|
||||
@@ -122,7 +122,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung
|
||||
'audit_table_user' => 'Benutzer',
|
||||
'audit_table_event' => 'Ereignis',
|
||||
'audit_table_related' => 'Verknüpfter Eintrag oder Detail',
|
||||
'audit_table_ip' => 'IP Address',
|
||||
'audit_table_ip' => 'IP Adresse',
|
||||
'audit_table_date' => 'Aktivitätsdatum',
|
||||
'audit_date_from' => 'Zeitraum von',
|
||||
'audit_date_to' => 'Zeitraum bis',
|
||||
@@ -251,6 +251,7 @@ Hinweis: Benutzer können ihre E-Mail Adresse nach erfolgreicher Registrierung
|
||||
'de_informal' => 'Deutsch (Du)',
|
||||
'es' => 'Español',
|
||||
'es_AR' => 'Español Argentina',
|
||||
'et' => 'Estnisch',
|
||||
'fr' => 'Français',
|
||||
'he' => 'עברית',
|
||||
'hr' => 'Hrvatski',
|
||||
|
||||
@@ -15,7 +15,7 @@ return [
|
||||
'alpha_dash' => ':attribute kann nur Buchstaben, Zahlen und Bindestriche enthalten.',
|
||||
'alpha_num' => ':attribute kann nur Buchstaben und Zahlen enthalten.',
|
||||
'array' => ':attribute muss ein Array sein.',
|
||||
'backup_codes' => 'The provided code is not valid or has already been used.',
|
||||
'backup_codes' => 'Der angegebene Code ist ungültig oder wurde bereits verwendet.',
|
||||
'before' => ':attribute muss ein Datum vor :date sein.',
|
||||
'between' => [
|
||||
'numeric' => ':attribute muss zwischen :min und :max liegen.',
|
||||
@@ -99,7 +99,7 @@ return [
|
||||
],
|
||||
'string' => ':attribute muss eine Zeichenkette sein.',
|
||||
'timezone' => ':attribute muss eine valide zeitzone sein.',
|
||||
'totp' => 'The provided code is not valid or has expired.',
|
||||
'totp' => 'Der angegebene Code ist ungültig oder abgelaufen.',
|
||||
'unique' => ':attribute wird bereits verwendet.',
|
||||
'url' => ':attribute ist kein valides Format.',
|
||||
'uploaded' => 'Die Datei konnte nicht hochgeladen werden. Der Server akzeptiert möglicherweise keine Dateien dieser Größe.',
|
||||
|
||||
@@ -23,6 +23,10 @@ return [
|
||||
'saml_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system',
|
||||
'saml_invalid_response_id' => 'The request from the external authentication system is not recognised by a process started by this application. Navigating back after a login could cause this issue.',
|
||||
'saml_fail_authed' => 'Login using :system failed, system did not provide successful authorization',
|
||||
'oidc_already_logged_in' => 'Already logged in',
|
||||
'oidc_user_not_registered' => 'The user :name is not registered and automatic registration is disabled',
|
||||
'oidc_no_email_address' => 'Could not find an email address, for this user, in the data provided by the external authentication system',
|
||||
'oidc_fail_authed' => 'Login using :system failed, system did not provide successful authorization',
|
||||
'social_no_action_defined' => 'No action defined',
|
||||
'social_login_bad_response' => "Error received during :socialAccount login: \n:error",
|
||||
'social_account_in_use' => 'This :socialAccount account is already in use, Try logging in via the :socialAccount option.',
|
||||
|
||||
@@ -248,6 +248,7 @@ return [
|
||||
'de_informal' => 'Deutsch (Du)',
|
||||
'es' => 'Español',
|
||||
'es_AR' => 'Español Argentina',
|
||||
'et' => 'Eesti keel',
|
||||
'fr' => 'Français',
|
||||
'he' => 'עברית',
|
||||
'hr' => 'Hrvatski',
|
||||
|
||||
@@ -23,6 +23,10 @@ return [
|
||||
'saml_no_email_address' => 'No se pudo encontrar una dirección de correo electrónico, para este usuario, en los datos proporcionados por el sistema de autenticación externo',
|
||||
'saml_invalid_response_id' => 'La solicitud del sistema de autenticación externo no está reconocida por un proceso iniciado por esta aplicación. Navegar hacia atrás después de un inicio de sesión podría causar este problema.',
|
||||
'saml_fail_authed' => 'El inicio de sesión con :system falló, el sistema no proporcionó una autorización correcta',
|
||||
'oidc_already_logged_in' => 'Ya tenías la sesión iniciada',
|
||||
'oidc_user_not_registered' => 'El usuario :name no está registrado y el registro automático está deshabilitado',
|
||||
'oidc_no_email_address' => 'No se pudo encontrar una dirección de correo electrónico, para este usuario, en los datos proporcionados por el sistema de autenticación externo',
|
||||
'oidc_fail_authed' => 'El inicio de sesión con :system falló, el sistema no proporcionó una autorización correcta',
|
||||
'social_no_action_defined' => 'Acción no definida',
|
||||
'social_login_bad_response' => "Se ha recibido un error durante el acceso con :socialAccount error: \n:error",
|
||||
'social_account_in_use' => 'la cuenta :socialAccount ya se encuentra en uso, intente acceder a través de la opción :socialAccount .',
|
||||
|
||||
@@ -248,6 +248,7 @@ return [
|
||||
'de_informal' => 'Deutsch (Du)',
|
||||
'es' => 'Español',
|
||||
'es_AR' => 'Español Argentina',
|
||||
'et' => 'Eesti keel',
|
||||
'fr' => 'Français',
|
||||
'he' => 'עברית',
|
||||
'hr' => 'Hrvatski',
|
||||
|
||||
@@ -48,8 +48,8 @@ return [
|
||||
'favourite_remove_notification' => '".name" se eliminó de sus favoritos',
|
||||
|
||||
// MFA
|
||||
'mfa_setup_method_notification' => 'Método de Autenticación en Dos Pasos configurado correctamente',
|
||||
'mfa_remove_method_notification' => 'Método de Autenticación en Dos Pasos eliminado correctamente',
|
||||
'mfa_setup_method_notification' => 'Método de autenticación de múltiples factores configurado satisfactoriamente',
|
||||
'mfa_remove_method_notification' => 'Método de autenticación de múltiples factores eliminado satisfactoriamente',
|
||||
|
||||
// Other
|
||||
'commented_on' => 'comentado',
|
||||
|
||||
@@ -76,37 +76,37 @@ return [
|
||||
'user_invite_success' => 'Contraseña establecida, ahora tiene acceso a :appName!',
|
||||
|
||||
// Multi-factor Authentication
|
||||
'mfa_setup' => 'Configurar Autenticación en Dos Pasos',
|
||||
'mfa_setup_desc' => 'La autenticación en dos pasos añade una capa de seguridad adicional a tu cuenta de usuario.',
|
||||
'mfa_setup' => 'Configurar autenticación de múltiples factores',
|
||||
'mfa_setup_desc' => 'Configure la autenticación de múltiples factores como una capa extra de seguridad para su cuenta de usuario.',
|
||||
'mfa_setup_configured' => 'Ya está configurado',
|
||||
'mfa_setup_reconfigure' => 'Reconfigurar',
|
||||
'mfa_setup_remove_confirmation' => '¿Está seguro de que desea eliminar este método de autenticación de dos pasos?',
|
||||
'mfa_setup_remove_confirmation' => '¿Está seguro que desea eliminar este método de autenticación de múltiples factores?',
|
||||
'mfa_setup_action' => 'Configuración',
|
||||
'mfa_backup_codes_usage_limit_warning' => 'Quedan menos de 5 códigos de respaldo, Por favor, genera y almacena un nuevo conjunto antes de que te quedes sin códigos para evitar que te bloquees fuera de tu cuenta.',
|
||||
'mfa_option_totp_title' => 'Aplicación para móviles',
|
||||
'mfa_option_totp_desc' => 'Para utilizar la autenticación de dos pasos necesitarás una aplicación móvil que soporte TOTP como Google Authenticator, Authy o Microsoft Authenticator.',
|
||||
'mfa_backup_codes_usage_limit_warning' => 'Quedan menos de 5 códigos de respaldo, Por favor, genere y guarde un nuevo conjunto antes de que se quede sin códigos para evitar que se bloquee su cuenta.',
|
||||
'mfa_option_totp_title' => 'Aplicación móvil',
|
||||
'mfa_option_totp_desc' => 'Para utilizar la autenticación en dos pasos necesitará una aplicación móvil que soporte TOTP como Google Authenticator, Authy o Microsoft Authenticator.',
|
||||
'mfa_option_backup_codes_title' => 'Códigos de Respaldo',
|
||||
'mfa_option_backup_codes_desc' => 'Almacena de forma segura un conjunto de códigos de respaldo de un solo uso que puedes introducir para verificar tu identidad.',
|
||||
'mfa_option_backup_codes_desc' => 'Almacene de forma segura un conjunto de códigos de respaldo de un solo uso que pueda introducir para verificar su identidad.',
|
||||
'mfa_gen_confirm_and_enable' => 'Confirmar y Activar',
|
||||
'mfa_gen_backup_codes_title' => 'Configuración de Códigos de Respaldo',
|
||||
'mfa_gen_backup_codes_desc' => 'Guarda la siguiente lista de códigos en un lugar seguro. Al acceder al sistema podrás usar uno de los códigos como un segundo mecanismo de autenticación.',
|
||||
'mfa_gen_backup_codes_desc' => 'Guarde la siguiente lista de códigos en un lugar seguro. Al acceder al sistema podrá usar uno de los códigos como un segundo mecanismo de autenticación.',
|
||||
'mfa_gen_backup_codes_download' => 'Descargar Códigos',
|
||||
'mfa_gen_backup_codes_usage_warning' => 'Cada código sólo puede utilizarse una vez',
|
||||
'mfa_gen_backup_codes_usage_warning' => 'Cada código puede utilizarse sólo una vez',
|
||||
'mfa_gen_totp_title' => 'Configuración de Aplicación móvil',
|
||||
'mfa_gen_totp_desc' => 'Para utilizar la autenticación de dos pasos necesitarás una aplicación móvil que soporte TOTP como Google Authenticator, Authy o Microsoft Authenticator.',
|
||||
'mfa_gen_totp_desc' => 'Para utilizar la autenticación en dos pasos necesitará una aplicación móvil que soporte TOTP como Google Authenticator, Authy o Microsoft Authenticator.',
|
||||
'mfa_gen_totp_scan' => 'Escanea el código QR mostrado a continuación usando tu aplicación de autenticación preferida para empezar.',
|
||||
'mfa_gen_totp_verify_setup' => 'Verificar Configuración',
|
||||
'mfa_gen_totp_verify_setup_desc' => 'Verifica que todo está funcionando introduciendo un código, generado en tu aplicación de autenticación, en el campo de texto a continuación:',
|
||||
'mfa_gen_totp_provide_code_here' => 'Introduce aquí tu código generado por la aplicación',
|
||||
'mfa_verify_access' => 'Verificar Acceso',
|
||||
'mfa_verify_access_desc' => 'Tu cuenta de usuario requiere que confirmes tu identidad a través de un nivel adicional de verificación antes de que te conceda el acceso. Verifica tu identidad usando uno de los métodos configurados para continuar.',
|
||||
'mfa_verify_access_desc' => 'Su cuenta de usuario requiere que confirme su identidad a través de un nivel adicional de verificación antes de que se le conceda el acceso. Verifique su identidad usando uno de los métodos configurados para continuar.',
|
||||
'mfa_verify_no_methods' => 'No hay Métodos Configurados',
|
||||
'mfa_verify_no_methods_desc' => 'No se han encontrado métodos de autenticación de dos pasos para tu cuenta. Tendrás que configurar al menos un método antes de obtener acceso.',
|
||||
'mfa_verify_no_methods_desc' => 'No se han encontrado métodos de autenticación de múltiples factores para su cuenta. Tendrá que configurar al menos un método antes de obtener acceso.',
|
||||
'mfa_verify_use_totp' => 'Verificar usando una aplicación móvil',
|
||||
'mfa_verify_use_backup_codes' => 'Verificar usando un código de respaldo',
|
||||
'mfa_verify_backup_code' => 'Códigos de Respaldo',
|
||||
'mfa_verify_backup_code' => 'Código de Respaldo',
|
||||
'mfa_verify_backup_code_desc' => 'Introduzca uno de sus códigos de respaldo restantes a continuación:',
|
||||
'mfa_verify_backup_code_enter_here' => 'Introduce el código de respaldo aquí',
|
||||
'mfa_verify_totp_desc' => 'Introduzca el código, generado con tu aplicación móvil, a continuación:',
|
||||
'mfa_setup_login_notification' => 'Método de dos factores configurado. Por favor, inicia sesión de nuevo utilizando el método configurado.',
|
||||
'mfa_verify_backup_code_enter_here' => 'Introduzca el código de respaldo aquí',
|
||||
'mfa_verify_totp_desc' => 'A continuación, introduzca el código generado con su aplicación móvil:',
|
||||
'mfa_setup_login_notification' => 'Método de dos factores configurado. Por favor, inicie sesión nuevamente utilizando el método configurado.',
|
||||
];
|
||||
@@ -234,7 +234,7 @@ return [
|
||||
'pages_initial_name' => 'Página nueva',
|
||||
'pages_editing_draft_notification' => 'Usted está actualmente editando un borrador que fue guardado por última vez el :timeDiff.',
|
||||
'pages_draft_edited_notification' => 'Esta página ha sido actualizada desde aquel momento. Se recomienda que cancele este borrador.',
|
||||
'pages_draft_page_changed_since_creation' => 'This page has been updated since this draft was created. It is recommended that you discard this draft or take care not to overwrite any page changes.',
|
||||
'pages_draft_page_changed_since_creation' => 'Esta página fue actualizada desde que se creó este borrador. Se recomienda descartar este borrador o tener cuidado de no sobrescribir ningún cambio en la página.',
|
||||
'pages_draft_edit_active' => [
|
||||
'start_a' => ':count usuarios han comenzado a editar esta página',
|
||||
'start_b' => ':userName ha comenzado a editar esta página',
|
||||
|
||||
@@ -23,6 +23,10 @@ return [
|
||||
'saml_no_email_address' => 'No se pudo encontrar una dirección de correo electrónico, para este usuario, en los datos proporcionados por el sistema de autenticación externo',
|
||||
'saml_invalid_response_id' => 'La solicitud del sistema de autenticación externo no está reconocida por un proceso iniciado por esta aplicación. Navegar hacia atrás después de un inicio de sesión podría causar este problema.',
|
||||
'saml_fail_authed' => 'El inicio de sesión con :system falló, el sistema no proporcionó una autorización correcta',
|
||||
'oidc_already_logged_in' => 'Ya tenías la sesión iniciada',
|
||||
'oidc_user_not_registered' => 'El usuario :name no está registrado y el registro automático está deshabilitado',
|
||||
'oidc_no_email_address' => 'No se pudo encontrar una dirección de correo electrónico, para este usuario, en los datos proporcionados por el sistema de autenticación externo',
|
||||
'oidc_fail_authed' => 'El inicio de sesión con :system falló, el sistema no proporcionó una autorización correcta',
|
||||
'social_no_action_defined' => 'Acción no definida',
|
||||
'social_login_bad_response' => "SE recibió un Error durante el acceso con :socialAccount : \n:error",
|
||||
'social_account_in_use' => 'la cuenta :socialAccount ya se encuentra en uso, intente loguearse a través de la opcón :socialAccount .',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user