Compare commits
10 Commits
8647008205
...
4d25f714b5
| Author | SHA1 | Date |
|---|---|---|
|
|
4d25f714b5 | |
|
|
2c0061a177 | |
|
|
dc7a98169d | |
|
|
ff94a81350 | |
|
|
af1f2ecef6 | |
|
|
3515497ffd | |
|
|
1da65c1c38 | |
|
|
68475fe015 | |
|
|
3a54da0e8e | |
|
|
1cf6dbebbd |
|
|
@ -3,7 +3,7 @@
|
||||||
# sio_2425_project
|
# sio_2425_project
|
||||||
|
|
||||||
# Group members
|
# Group members
|
||||||
- A
|
|
||||||
- B
|
|
||||||
- C
|
|
||||||
|
|
||||||
|
- João Pedro Fonseca Bastos - 113470 - joaop.bastos@ua.pt
|
||||||
|
- Rúben da Loura Cristóvão Gomes - 113435 - rlcg@ua.pt
|
||||||
|
- Tiago Rocha Garcia - 114184 - tiago.rgarcia@ua.pt
|
||||||
|
|
|
||||||
|
|
@ -1 +1,11 @@
|
||||||
|
# SIO-2425 Report
|
||||||
|
|
||||||
|
## Group
|
||||||
|
|
||||||
|
- João Pedro Fonseca Bastos - 113470 - joaop.bastos@ua.pt
|
||||||
|
- Rúben da Loura Cristóvão Gomes - 113435 - rlcg@ua.pt
|
||||||
|
- Tiago Rocha Garcia - 114184 - tiago.rgarcia@ua.pt
|
||||||
|
|
||||||
|
## Report
|
||||||
|
|
||||||
|
This report was written in [AsciiDoc](https://asciidoctor.org/) and compiled using the `asciidoctor` tool.
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
== Analysis
|
[.analysis]
|
||||||
|
== ASVS Analysis
|
||||||
|
|
||||||
For the analysis section, the project will be evaluated under the scope of the V3 (Session Management) chapter of the OWASP ASVS, using version v4.0.3. This will include an assessment of the session management mechanisms implemented, as well as any vulnerabilities identified and possible mitigations.
|
For the analysis section, the project will be evaluated under the scope of the V3 (Session Management) chapter of the OWASP ASVS, using version v4.0.3. This will include an assessment of the session management mechanisms implemented, as well as any vulnerabilities identified and possible mitigations.
|
||||||
|
|
||||||
=== Session Management
|
=== V3 Session Management
|
||||||
|
|
||||||
==== Fundamental Session Management Security
|
==== Fundamental Session Management Security
|
||||||
|
|
||||||
|
|
@ -21,6 +22,17 @@ For the analysis section, the project will be evaluated under the scope of the V
|
||||||
|
|
||||||
The current implementation meets the requirement, as the session tokens are not exposed in the URL parameters but instead are sent in the Authorization header.
|
The current implementation meets the requirement, as the session tokens are not exposed in the URL parameters but instead are sent in the Authorization header.
|
||||||
|
|
||||||
|
This way, instead of parsing the URL for the token, the server can directly access the token from the header, which is a more secure method of handling session tokens. This is done with the following line of code (including the necessary error handling):
|
||||||
|
|
||||||
|
[source,python]
|
||||||
|
----
|
||||||
|
token = request.headers.get("Authorization")
|
||||||
|
if not session_token:
|
||||||
|
return jsonify({"error": "No session token"}), 400
|
||||||
|
----
|
||||||
|
|
||||||
|
This piece of code is present in all endpoints that require a session token, ensuring that the token is always sent in the header and never in the URL.
|
||||||
|
|
||||||
==== Session Binding
|
==== Session Binding
|
||||||
|
|
||||||
[cols="^1,10,^1,^1", options="header", source]
|
[cols="^1,10,^1,^1", options="header", source]
|
||||||
|
|
@ -48,12 +60,13 @@ The current implementation meets the requirement, as the session tokens are not
|
||||||
| ✔
|
| ✔
|
||||||
|===
|
|===
|
||||||
|
|
||||||
|
[[reqs_3_2_x]]
|
||||||
===== 3.2.1, 3.2.2, 3.2.4
|
===== 3.2.1, 3.2.2, 3.2.4
|
||||||
|
|
||||||
The application generates a new session token on session creation when a user logs in.
|
The application generates a new session token on session creation when a user logs in.
|
||||||
|
|
||||||
This token is generated using the `secrets.token_hex(128)`
|
This token is generated using the `secrets.token_hex(128)`
|
||||||
function, which generates a 256-character hexadecimal string, providing more than 64 bits of entropy. This function has been certified as secure by OWASP in their link:https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html#secure-random-number-generation[cheat sheet series].
|
function, which generates a 256-character hexadecimal string, providing more than 64 bits of entropy. This function has been certified as secure by OWASP in their https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html#secure-random-number-generation[cheat sheet series] footnote:[https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html#secure-random-number-generation].
|
||||||
|
|
||||||
This generation is implemented in the code as follows:
|
This generation is implemented in the code as follows:
|
||||||
|
|
||||||
|
|
@ -63,7 +76,7 @@ def create_session(user: User, org: Organization) -> Session:
|
||||||
session = Session(
|
session = Session(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
org_id=org.id,
|
org_id=org.id,
|
||||||
token=secrets.token_hex(128),
|
token=secrets.token_hex(128), # 256-character hexadecimal string
|
||||||
roles=[],
|
roles=[],
|
||||||
challenge=secrets.token_hex(128),
|
challenge=secrets.token_hex(128),
|
||||||
verified=False
|
verified=False
|
||||||
|
|
@ -74,6 +87,10 @@ def create_session(user: User, org: Organization) -> Session:
|
||||||
return session
|
return session
|
||||||
----
|
----
|
||||||
|
|
||||||
|
===== 3.2.3
|
||||||
|
|
||||||
|
This requirement is not applicable to the current implementation, as there is no browser involved with the application and therefore not used to store any session tokens.
|
||||||
|
|
||||||
==== Session Termination
|
==== Session Termination
|
||||||
|
|
||||||
[cols="^1,10,^1,^1", options="header", source]
|
[cols="^1,10,^1,^1", options="header", source]
|
||||||
|
|
@ -126,16 +143,65 @@ def check_session_timeout(session: Session) -> bool:
|
||||||
return (datetime.now() - session.last_used).total_seconds() > SESSION_TIMEOUT
|
return (datetime.now() - session.last_used).total_seconds() > SESSION_TIMEOUT
|
||||||
----
|
----
|
||||||
|
|
||||||
|
===== 3.3.3
|
||||||
|
|
||||||
|
This requirement is not applicable to the current implementation, as the application doesn't use password for logins, and therefore doesn't have a password change mechanism and thus not having the mechanism to terminate all other active sessions after a successful password change.
|
||||||
|
|
||||||
===== 3.3.4
|
===== 3.3.4
|
||||||
|
|
||||||
Currently, there isn't a mechanism to view and log out of active sessions and devices.
|
Currently, there isn't a mechanism to view and log out of active sessions and devices.
|
||||||
|
|
||||||
This could be implemented by storing the device information in the session data and enabling an endpoint for the user to view all active sessions and devices, and then revoke access to them.
|
This could be implemented by storing the device information in the session data and enabling an endpoint for the user to view all active sessions and devices, and then revoke access to them. This endpoint could be implemented as follows:
|
||||||
|
|
||||||
|
[source,python]
|
||||||
|
----
|
||||||
|
@user_bp.route("/sessions", methods=["GET"])
|
||||||
|
def list_sessions():
|
||||||
|
session_token = request.headers.get("Authorization")
|
||||||
|
if not session_token:
|
||||||
|
return jsonify({"error": "No session token"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = SessionService.validate_session(session_token)
|
||||||
|
except SessionException as e:
|
||||||
|
return jsonify({"error": e.message}), e.code
|
||||||
|
|
||||||
|
user = UserService.get_user(session.user_id)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "User not found"}), 404
|
||||||
|
|
||||||
|
sessions = SessionService.get_user_sessions(user)
|
||||||
|
return jsonify({"sessions": sessions}), 200
|
||||||
|
----
|
||||||
|
|
||||||
|
This would return a list of all active sessions for the user, and then the user could choose to revoke access to any of them, using the session id (not the token) through the following endpoint:
|
||||||
|
|
||||||
|
[source,python]
|
||||||
|
----
|
||||||
|
@user_bp.route("/logout/<session_id>", methods=["POST"])
|
||||||
|
def user_logout_session(session_id):
|
||||||
|
session_token = request.headers.get("Authorization")
|
||||||
|
if not session_token:
|
||||||
|
return jsonify({"error": "No session token"}), 400
|
||||||
|
|
||||||
|
current_session = SessionService.get_session(session_token)
|
||||||
|
if not current_session:
|
||||||
|
return jsonify({"error": "Not authenticated"}), 401
|
||||||
|
|
||||||
|
session = SessionService.get_session_by_id(session_id)
|
||||||
|
if not session:
|
||||||
|
return jsonify({"error": "Session not found"}), 404
|
||||||
|
|
||||||
|
if session.user_id != current_session.user_id:
|
||||||
|
return jsonify({"error": "Unauthorized"}), 403
|
||||||
|
|
||||||
|
SessionService.delete_session(session)
|
||||||
|
return jsonify({"message": f"Logged out from session with id {session_id}"}), 200
|
||||||
|
----
|
||||||
|
|
||||||
|
|
||||||
==== Cookie-based Session Management
|
==== Cookie-based Session Management
|
||||||
|
|
||||||
NOTE: None of the requirements in this section are applicable to the current implementation.
|
|
||||||
|
|
||||||
[cols="^1,10,^1,^1", options="header", source]
|
[cols="^1,10,^1,^1", options="header", source]
|
||||||
|===
|
|===
|
||||||
| Requirement | Description | Applicable | Implemented
|
| Requirement | Description | Applicable | Implemented
|
||||||
|
|
@ -166,6 +232,10 @@ NOTE: None of the requirements in this section are applicable to the current imp
|
||||||
| ✗
|
| ✗
|
||||||
|===
|
|===
|
||||||
|
|
||||||
|
===== 3.4.1, 3.4.2, 3.4.3, 3.4.4, 3.4.5
|
||||||
|
|
||||||
|
None of the requirements are applicable to the current implementation, as the application does not use cookies to store any data or to manage sessions.
|
||||||
|
|
||||||
==== Token-based Session Management
|
==== Token-based Session Management
|
||||||
|
|
||||||
[cols="^1,10,^1,^1", options="header", source]
|
[cols="^1,10,^1,^1", options="header", source]
|
||||||
|
|
@ -188,24 +258,48 @@ NOTE: None of the requirements in this section are applicable to the current imp
|
||||||
| ✗
|
| ✗
|
||||||
|===
|
|===
|
||||||
|
|
||||||
|
===== 3.5.1
|
||||||
|
|
||||||
|
This requirement is not applicable to the current implementation, as there is no OAuth service implemented in the application.
|
||||||
|
|
||||||
===== 3.5.2
|
===== 3.5.2
|
||||||
|
|
||||||
The application uses that exact implementation, using session tokens instead of static API secrets and keys to manage sessions.
|
The application uses that exact implementation, using session tokens instead of static API secrets and keys to manage sessions as mentioned in the link:#reqs_3_2_x[section 3.2] of the ASVS.
|
||||||
|
|
||||||
|
The server generates a session token upon login, using the function `secrets.token_hex(128)`, and sends it to the client. The client then sends the token in the Authorization header in every request that requires authentication.
|
||||||
|
|
||||||
===== 3.5.3
|
===== 3.5.3
|
||||||
|
|
||||||
TBD
|
Since this topic involves a few different types of attacks, it's best to address them individually:
|
||||||
|
|
||||||
|
====== Digital Signatures and Replay
|
||||||
|
These attacks could be tackled with the use of JWT (JSON Web Tokens). They support a `exp` field, that indicate the expiry date of said packet (protects against Replay attacks). Expiry time could be adjusted dynamically according to client-server ping.
|
||||||
|
|
||||||
|
JWT also supports signatures, so with a given secret key, the server can verify the integrity of the token. The signature is calculated using the header and payload of the token (with the content of the token being base64 encoded).
|
||||||
|
|
||||||
|
====== Tampering
|
||||||
|
With the support of signatures, the server can verify the integrity of the token, and if it was tampered with, the signature would not match the content of the token.
|
||||||
|
|
||||||
|
Another added layer of security could be the use of a counter, that is incremented with each request, where the server verifies if the counter is correct, and vice versa.
|
||||||
|
|
||||||
|
====== Null Cipher
|
||||||
|
The cipher python package used in the project, `cryptohazmat`, uses AES-CFB, that avoids null ciphers. Of course, any failure within the package (or any other package) due to an update or a bug could eventually lead to a null cipher attack.
|
||||||
|
|
||||||
|
====== Key Substitution
|
||||||
|
Since it is being used a Diffie-Hellman key exchange, the key is never sent over the network, the server and client end up with the same key, so there is no key to be substituted. It is also not possible to substitute a key, that would require re-authentication.
|
||||||
|
|
||||||
|
====== Encryption
|
||||||
|
The current implementation does not encrypt the session token, which could be a vulnerability. This could be implemented by encrypting the session token with a symmetric key (and use a strong algorithm, like AES-256), and then decrypting it on the server side. This would protect the token from being read by an attacker, even if they manage to intercept it.
|
||||||
|
|
||||||
|
|
||||||
==== Federated Re-authentication
|
==== Federated Re-authentication
|
||||||
|
|
||||||
NOTE: None of the requirements in this section are applicable to the current implementation.
|
|
||||||
|
|
||||||
[cols="^1,10,^1,^1", options="header", source]
|
[cols="^1,10,^1,^1", options="header", source]
|
||||||
|===
|
|===
|
||||||
| Requirement | Description | Applicable | Implemented
|
| Requirement | Description | Applicable | Implemented
|
||||||
|
|
||||||
| 3.6.1
|
| 3.6.1
|
||||||
| Verify that Relying Parties (RPs) specify the maximum authentication time to Credential Service Providers (CSPs) and that CSPs re- authenticate the user if they haven't used a session within that period.
|
| Verify that Relying Parties (RPs) specify the maximum authentication time to Credential Service Providers (CSPs) and that CSPs re-authenticate the user if they haven't used a session within that period.
|
||||||
| ✗
|
| ✗
|
||||||
| ✗
|
| ✗
|
||||||
|
|
||||||
|
|
@ -215,6 +309,10 @@ NOTE: None of the requirements in this section are applicable to the current imp
|
||||||
| ✗
|
| ✗
|
||||||
|===
|
|===
|
||||||
|
|
||||||
|
===== 3.6.1, 3.6.2
|
||||||
|
|
||||||
|
These requirements are not applicable to the current implementation, as the application does not have a federated authentication system.
|
||||||
|
|
||||||
==== Defenses Against Session Management Exploits
|
==== Defenses Against Session Management Exploits
|
||||||
|
|
||||||
[cols="^1,10,^1,^1", options="header", source]
|
[cols="^1,10,^1,^1", options="header", source]
|
||||||
|
|
@ -231,4 +329,4 @@ NOTE: None of the requirements in this section are applicable to the current imp
|
||||||
|
|
||||||
Currently, the application does not require re-authentication or secondary verification before allowing sensitive transactions or account modifications, it just checks if the user is authenticated and has the required permissions.
|
Currently, the application does not require re-authentication or secondary verification before allowing sensitive transactions or account modifications, it just checks if the user is authenticated and has the required permissions.
|
||||||
|
|
||||||
This could be implemented by adding a challenge signature to the request using the rsa key pair, which would be verified by the server before allowing the transaction. This is the same mechanism already used for the login endpoint.
|
This could be implemented by adding a challenge signature to the request using the rsa key pair, which would be verified by the server before allowing the transaction. This is the same mechanism already used for the login endpoint. To implement this, every endpoint that involves sensitive transactions or account modifications would need to be updated to include the challenge generation and signature verification.
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
== Conclusions
|
||||||
|
|
||||||
|
The SIO-2425 project successfully demonstrates the practical application of critical security principles, including authentication, access control, session management, and cryptography. Through the implementation of modularized server architecture, robust session handling, and encrypted communication mechanisms, the project adheres to some industry standards, where analyzing the OWASP ASVS gives the developers a broader landscape of what are the best practices, and what needs to be done in order to achieve a more secure application, even though of course it'll never be fully secure.
|
||||||
|
|
||||||
|
Despite these achievements, the analysis highlighted areas requiring further improvement, such as enhanced mechanisms for session re-authentication, secure session management features, and the ability to terminate active sessions after sensitive changes. Addressing these issues would further improve the system's security posture and resilience against potential vulnerabilities.
|
||||||
|
|
||||||
|
The methodologies and decisions applied throughout this project underline the importance of secure design in software development. By integrating tools like Diffie-Hellman key exchange, AES encryption, and SHA256 hashing, the project ensures data confidentiality, integrity, and authenticity. It was also a great learning opportunity for the authors, when it comes to server-side development and design, as well as the importance of secure coding practices.
|
||||||
|
|
||||||
|
Future work could focus on refining the system to meet additional ASVS requirements and expanding its usability in real-world applications. Overall, this project stands as a testament to the successful implementation of secure application principles and the importance of continuous learning and iteration in cybersecurity practices.
|
||||||
|
|
@ -1,3 +1,87 @@
|
||||||
== Decisions
|
== Decisions
|
||||||
|
|
||||||
The methodology section will delineate the steps undertaken to achieve the project objectives, including the tools and technologies utilized, as well as any challenges encountered and the strategies employed to address them.
|
In this topic, it'll be presented and discussed the decisions made by the authors of the project thorough its development. These can range from choices taken to be used for encryption to the implementation of the API itself.
|
||||||
|
|
||||||
|
=== Usage of the Diffie-Hellman Key Exchange
|
||||||
|
|
||||||
|
The Diffie-Hellman key exchange is used in this project to securely create and maintain a key where both ends (client and server) know that key, but never exchange it. Diffie-Hellman is great for this, since by exchanging the parameters used to generate the key, and each other's public key, both can end up with the same key, without ever needing to exchange it on a public channel.
|
||||||
|
|
||||||
|
The key generation (public and private) is done using the parameters agreed on both ends (using the `dh` package from `cryptography`).
|
||||||
|
|
||||||
|
=== Symmetric Encryption
|
||||||
|
|
||||||
|
The symmetric encryption used in this project is AES-CFB. This is a symmetric encryption algorithm that is widely used and considered secure. This cipher was chosen due to the FeedBack functionality, that avoids the same plaintext to be encrypted to the same ciphertext, which is a vulnerability present in the ECB mode.
|
||||||
|
|
||||||
|
This type of encryption is used to encrypt anonymous requests and files. Both use an Initialization Vector (IV) of 16 bytes, with it being randomly generated.
|
||||||
|
|
||||||
|
[[decisions_authentication]]
|
||||||
|
=== Authentication
|
||||||
|
|
||||||
|
When it comes to authentication, there were multiple options to create a secure and reliable system. The chosen method was the same as the one used by ssh, which uses a key pair to authenticate the user.
|
||||||
|
|
||||||
|
The key pair is generated using the RSA algorithm, with a key size of 2048 bits. The public key is stored on the server, associated to the user, and the private key is kept privately by the user.
|
||||||
|
|
||||||
|
During the login, the client sends a login request with the username and organization, to which the server generates and returns a challenge (a string of 256 random characters). The client then signs this challenge with its private key and sends the signature back to the server. The server then verifies the signature using the public key associated with the user and, if the signature is valid, the user is authenticated and thus the session is validated.
|
||||||
|
|
||||||
|
=== File Handling
|
||||||
|
|
||||||
|
As mentioned before, the files are encrypted symmetrically. The key used to decrypt them is within its metadata, which can only be accessed with the required permissions. The encryption and decryption is done by blocks (or chunks) of 2^11^ bytes.
|
||||||
|
|
||||||
|
=== Session-Related Content
|
||||||
|
|
||||||
|
By default, all keys, files and session files are stored under `~/.sio` on the client side. In order to clear all files generated by the application, the user must run `rm -r ~/.sio/`
|
||||||
|
|
||||||
|
=== Hashing
|
||||||
|
|
||||||
|
For the hashing, it was used the SHA256 hashing mechanism, since it produces a 256 byte value. This is used to check the file integrity when going from Client → Server and vice versa.
|
||||||
|
|
||||||
|
=== Database
|
||||||
|
|
||||||
|
The authors of this project have chosen to use a database to store all server-side content (except for the files' content).
|
||||||
|
|
||||||
|
The database used in this project is SQLite3. This was chosen due to the authors' familiarity with it, its simplicity and the fact that it is a single file, which makes it easier to manage and distribute. SQLAlchemy was also used to interact with the database, as it provides a more abstract way to interact with the database and to have the ability to have the dataclasses corresponding to the tables. When needed, it can be reset using an endpoint mentioned in the link:#features[Features chapter].
|
||||||
|
|
||||||
|
=== Modularized Server
|
||||||
|
|
||||||
|
The server is modularized in order to make it easier to maintain and expand.
|
||||||
|
|
||||||
|
For this, Flask's Blueprints were used. This allows for the server to be divided into multiple modules, each with its own set of endpoints.
|
||||||
|
|
||||||
|
Each service has its own file and class, which makes it easier to understand and maintain. This also makes it easier to add new services to the server, as they can be added as new files and classes. This also allows for the endpoints to be easily changed as there are no real operations being done on them, but instead they are just calling the respective service.
|
||||||
|
|
||||||
|
=== Roles
|
||||||
|
|
||||||
|
The roles are used to define the permissions of each user. The permissions are stored, viewed and treated as seen in other services, like Discord. This approach is as follows:
|
||||||
|
|
||||||
|
[source,python]
|
||||||
|
----
|
||||||
|
class Perm(Enum):
|
||||||
|
DOC_ACL = 0b000000000001
|
||||||
|
DOC_READ = 0b000000000010
|
||||||
|
DOC_DELETE = 0b000000000100
|
||||||
|
#...
|
||||||
|
----
|
||||||
|
|
||||||
|
Since it is stored in bits, validating a permission or adding it to a role is very easy, since it'll be just bit-wise operations.
|
||||||
|
|
||||||
|
==== Checking a permission
|
||||||
|
|
||||||
|
To check if a role has a permission, it can be easily done by looking at the bit corresponding to the permission, and do a simple AND operation with said bit (that bit has to be 1). For this, the following function was created:
|
||||||
|
|
||||||
|
[source,python]
|
||||||
|
----
|
||||||
|
def check_perm(bit_array: int, perm_to_check: int) -> bool:
|
||||||
|
return bit_array & perm_to_check == perm_to_check
|
||||||
|
----
|
||||||
|
|
||||||
|
==== Changing a permission
|
||||||
|
|
||||||
|
To change a permission associated with a role, all that is needed is a OR operator (to add) or a AND operator (to remove) with the current role's permissions and the bit we want to enable (permission to give). This bit has to be also 1. For this, the following function was created, returning the resulting bit array:
|
||||||
|
|
||||||
|
[source,python]
|
||||||
|
----
|
||||||
|
def calc(bit_array: int, perm: Perm, operation: PermOperation) -> int:
|
||||||
|
if operation == PermOperation.ADD:
|
||||||
|
return bit_array | perm.value
|
||||||
|
return bit_array & ~perm.value
|
||||||
|
----
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
[[features]]
|
||||||
== Features
|
== Features
|
||||||
|
|
||||||
The features of the project are the ones present in the course project description, but with an extra feature, the possibility to reset the database of the server. This was shown to be useful for testing purposes, but it should be disabled/deleted in a production environment.
|
The features of the project are the ones present in the course project description, but with an extra feature, the possibility to reset the database of the server. This was shown to be useful for testing purposes, but it should be disabled/deleted in a production environment.
|
||||||
|
|
||||||
|
=== API Endpoints
|
||||||
|
|
||||||
The API has a list of endpoints that require different permission levels to access. Mainly, it's divided into 3 categories:
|
The API has a list of endpoints that require different permission levels to access. Mainly, it's divided into 3 categories:
|
||||||
|
|
||||||
|
|
@ -10,7 +12,7 @@ The API has a list of endpoints that require different permission levels to acce
|
||||||
* <<_authorized_endpoints,Authorized>>: Authentication and permissions required.
|
* <<_authorized_endpoints,Authorized>>: Authentication and permissions required.
|
||||||
|
|
||||||
[[_anonymous_endpoints]]
|
[[_anonymous_endpoints]]
|
||||||
=== Anonymous Endpoints
|
==== Anonymous Endpoints
|
||||||
|
|
||||||
[cols="1,1,1,1", options="header"]
|
[cols="1,1,1,1", options="header"]
|
||||||
|===
|
|===
|
||||||
|
|
@ -60,7 +62,7 @@ a| * `signature`: Signature of the challenge using the private key.
|
||||||
|===
|
|===
|
||||||
|
|
||||||
[[_authenticated_endpoints]]
|
[[_authenticated_endpoints]]
|
||||||
=== Authenticated Endpoints
|
==== Authenticated Endpoints
|
||||||
|
|
||||||
[cols="1,1,1,1", options="header", source]
|
[cols="1,1,1,1", options="header", source]
|
||||||
|===
|
|===
|
||||||
|
|
@ -124,7 +126,7 @@ a| * `Authorization: token`
|
||||||
|===
|
|===
|
||||||
|
|
||||||
[[_authorized_endpoints]]
|
[[_authorized_endpoints]]
|
||||||
=== Authorized Endpoints
|
==== Authorized Endpoints
|
||||||
|
|
||||||
[cols="1,1,1,1", options="header", source]
|
[cols="1,1,1,1", options="header", source]
|
||||||
|===
|
|===
|
||||||
|
|
@ -219,3 +221,299 @@ a| * `Authorization: token`
|
||||||
| ROLE_MOD
|
| ROLE_MOD
|
||||||
|
|
||||||
|===
|
|===
|
||||||
|
|
||||||
|
<<<
|
||||||
|
|
||||||
|
=== Client Interaction
|
||||||
|
|
||||||
|
For the client, each command is executed via terminal, and it is used multiple tools with specific functionalities:
|
||||||
|
|
||||||
|
* `argparse` footnote:[https://docs.python.org/3/library/argparse.html] → Check for errors in arguments given by the user.
|
||||||
|
* `logging` footnote:[https://docs.python.org/3/library/logging.html] → Logging system to send out messages such as errors.
|
||||||
|
* `os` footnote:[https://docs.python.org/3/library/os.html] → Add path to local folder `~/.sio` to save or load any files used by the current command.
|
||||||
|
* `requests` footnote:[https://requests.readthedocs.io/en/latest/] → Main library to allow communication from the client to the API.
|
||||||
|
* `JSON` footnote:[https://docs.python.org/3/library/json.html] → Main library to store and exchange data between the client and API.
|
||||||
|
|
||||||
|
For every command the argument `-r` is present to set the APIs address. It is needed to define if it wasn't previously, otherwise an error is cast with the corresponding message.
|
||||||
|
|
||||||
|
|
||||||
|
==== Generating the Subject's Credentials
|
||||||
|
To use the API, it is first needed to create a public key to create an organization with the key.
|
||||||
|
The command `rep_subject_credentials` generates a key-pair using RSA with a given password and saves both public and private keys different files under the folder mentioned before.
|
||||||
|
|
||||||
|
|
||||||
|
==== Creating an Organization
|
||||||
|
The command `rep_create_org` creates an organization. In order to do that, the client must give the file containing his public key in order to create a session afterwards.
|
||||||
|
|
||||||
|
|
||||||
|
==== Creating a session
|
||||||
|
For the client to use the Authenticated API, the command `rep_create_session` allows the user to create a session and assume an identity. This command also protects information that shouldn't be visible to outsiders when it's being transferred between the client and the server.
|
||||||
|
|
||||||
|
To protect the information, the client and the server initiate a Diffie-Hellman footnote:[https://cryptography.io/en/latest/hazmat/primitives/asymmetric/dh/] key exchange where both create a key pair with the same parameters and share each other their public key to derive with their own private key and obtain a common key which can be used to encrypt and decrypt information between both entities.
|
||||||
|
|
||||||
|
[source, python]
|
||||||
|
----
|
||||||
|
# generate the parameters and the key pair
|
||||||
|
generator = 2; key_size = 1024
|
||||||
|
parameters = generate_parameters(generator, key_size)
|
||||||
|
private_key, public_key = generate_key_pair(parameters)
|
||||||
|
|
||||||
|
# send the parameters and the public key to the server
|
||||||
|
req = requests.post('json with parameters and public key...')
|
||||||
|
|
||||||
|
# receive the server's public key
|
||||||
|
response = req.get('json with server public key...')
|
||||||
|
server_public_key = serialization.load_pem_public_key(bytes.fromhex(response['public_key']))
|
||||||
|
|
||||||
|
# obtain the derived key
|
||||||
|
derived_key = derive_keys(private_key, server_public_key)
|
||||||
|
----
|
||||||
|
|
||||||
|
If the exchange is successful, the client will attempt to log in using its private key that should be given when executing this command. This process is explained in the decision chapter, under link:#decisions_authentication[Authentication], but in short, the server will send a challenge to the client, which the client will sign with its private key (from a different key pair from Diffie-Hellman) and send back to the server. If the server can verify the signature, the client is authenticated and a session is created. This is the same method used in SSH.
|
||||||
|
|
||||||
|
==== Listing Organizations
|
||||||
|
The command `rep_list_org` lists all organizations present in the server. Since it requires no authentication, it is a simple command that sends a GET request to the API and prints the response.
|
||||||
|
|
||||||
|
However, it is important to note that the response is encrypted and must be decrypted before being printed. In order to achieve this, the client uses a symmetric encryption algorithm that uses an Initialization Vector (IV) to decrypt the response. This is done by the function `decrypt_request_with_iv` that receives the encrypted response, where it separates the IV from the encrypted text (first 16 bytes being the IV) and returns the decrypted response.
|
||||||
|
|
||||||
|
[source, python]
|
||||||
|
----
|
||||||
|
def decrypt_request_with_iv(response):
|
||||||
|
iv = response[:16]
|
||||||
|
cipher = response[16:]
|
||||||
|
|
||||||
|
# decrypt the response (simplified)
|
||||||
|
plaintext = decrypt(cipher, iv)
|
||||||
|
|
||||||
|
# need to decode the bytes to string
|
||||||
|
return plaintext.decode()
|
||||||
|
----
|
||||||
|
|
||||||
|
It could be argued that if a user is authenticated, the response could be encrypted/decrypted using the derived key from the Diffie-Hellman key exchange, but since the information is publicly available, it's not really needed.
|
||||||
|
|
||||||
|
|
||||||
|
==== Listing Users of an Organization
|
||||||
|
The command `rep_list_users` lists all users of an organization. It requires the user to be authenticated and the response is encrypted with the derived key from the Diffie-Hellman key exchange. Optionally, the user can query for a specific user by providing the username as an argument.
|
||||||
|
|
||||||
|
First, the API checks if the user is authenticated (has a session file) and the username was given as an argument. Then, it sends a `GET` request to the server. The data of the request is encrypted with the derived key, created with the initialization of the session.
|
||||||
|
|
||||||
|
Finally, if the request was successful, the data is decrypted and printed to the screen.
|
||||||
|
|
||||||
|
|
||||||
|
==== Assuming a role
|
||||||
|
With the command `rep_assume_role`, the user can assume a role in the session. This command requires the user to be authenticated and the role to be assumed. The role is sent to the server in a `POST` request, where the role name is used as a parameter in the URL. This way, there is no need to send anything in the body of the request, therefore there is no need to encrypt the data. The server will check if the user has the permission to assume the role and if the role exists.
|
||||||
|
|
||||||
|
If everything is correct, the server will return a 200 status code, otherwise it will return an error message. The API will print the message to the screen and exit with the corresponding status code.
|
||||||
|
|
||||||
|
<<<
|
||||||
|
|
||||||
|
==== Adding a role to the organization
|
||||||
|
The command `rep_add_role` allows the user to add a role to the organization. This command requires the user to be authenticated and the role to be added. The role is sent to the server in a `POST` request, where the role name is sent in the body of the request. The data is encrypted with the derived key from the Diffie-Hellman key exchange.
|
||||||
|
|
||||||
|
If the request is successful, the server will return a 201 status code, otherwise it will return an error message. The API will print the message to the screen and exit with the corresponding status code, as per usual.
|
||||||
|
|
||||||
|
|
||||||
|
==== Adding permissions/user to a role
|
||||||
|
Given a role name and a valid permission/username, the `rep_add_permission` command allows a user to add certain permissions to a specific role in an organization (if the user has permissions to do that) or a username to that role.
|
||||||
|
|
||||||
|
First, since there's only some permissions, it's checked if it corresponds to one of them and if not, it assumes it's a username.
|
||||||
|
|
||||||
|
It then sends a `POST` request to the server, with the role and permission/username on the URL. With this of course, there is no need for encryption. It also sends the session token (which could be encrypted as mentioned in the analysis) in the header.
|
||||||
|
|
||||||
|
Like the other commands, if the request is successful, the server will return a 201 status code, otherwise it will return an error message. The API will print the message to the screen and exit with the corresponding status code.
|
||||||
|
|
||||||
|
|
||||||
|
==== Removing a permission/user from a role
|
||||||
|
Similar to the previous command, the `rep_remove_permission` command allows a user to remove certain permissions from a specific role in an organization (if the user has permissions to do that) or a username from that role.
|
||||||
|
|
||||||
|
It follows the same steps as the `rep_add_permission` command, but instead of adding, it removes the permission/username from the role.
|
||||||
|
|
||||||
|
|
||||||
|
==== Suspending a role
|
||||||
|
The command `rep_suspend_role` allows the user to suspend a role in the organization. This command requires the user to be authenticated and the role to be suspended. The role is sent to the server in a `POST` request, where the role name is used as a parameter in the URL. This way, there is no need to send anything in the body of the request, therefore there is no need to encrypt the data. The server will check if the user has the permission to suspend the role.
|
||||||
|
|
||||||
|
The session token is also sent in the header, so that the server knows who is attempting to access the endpoint.
|
||||||
|
|
||||||
|
An error can occur if the role provided oes not exist (since the endpoint doesn't exist, it will return a "Failed to obtain response from server." error).
|
||||||
|
|
||||||
|
If everything is correct, the server will return a 200 status code, otherwise it will return an error message. The API will print the message to the screen and exit with the corresponding status code.
|
||||||
|
|
||||||
|
|
||||||
|
==== Reactivating a role
|
||||||
|
The command `rep_reactivate_role` allows the user to reactivate a role in the organization. This command requires the user to be authenticated and the role to be reactivated. The role is sent to the server in a `POST` request, where the role name is used as a parameter in the URL. This way, there is no need to send anything in the body of the request, therefore there is no need to encrypt the data. The server will also check if the user has the permission to reactivate the role.
|
||||||
|
|
||||||
|
This command is similar to `rep_suspend_role`, but instead of suspending a role, it reactivates a suspended role.
|
||||||
|
|
||||||
|
|
||||||
|
==== Dropping a role
|
||||||
|
This command (`rep_drop_role`) is, once again, similar to the previous commands in terms of how it works. The only difference being that it makes the current user drop the provided role.
|
||||||
|
|
||||||
|
|
||||||
|
==== Listing the existing roles
|
||||||
|
The command `rep_list_roles` requires a session token in order to function. Given that the token is present, it sends a simple `GET` request to the server, where it returns the existing roles in the current organization.
|
||||||
|
|
||||||
|
The returning content is encrypted using the Diffie-Hellman derived key, calculated when the session was created.
|
||||||
|
|
||||||
|
Like before, the roles are printed to the screen, and any errors would also be printed, with the corresponding exit codes properly returned.
|
||||||
|
|
||||||
|
|
||||||
|
==== Listing subjects with a role
|
||||||
|
The following command `rep_list_role_subjects` is similar to the previous command (`rep_list_roles`), except this command will return the subjects that have a specific role. This role is given as an argument and should be provided beforehand when executing the command.
|
||||||
|
|
||||||
|
|
||||||
|
==== Listing roles of a subject
|
||||||
|
This command (`rep_list_subject_roles`) is very similar to the `rep_list_role_subjects` command, but with one key difference. This command is meant to list the roles of a subject. The implementation of the command is, as mentioned before, similar to the role subjects command.
|
||||||
|
|
||||||
|
|
||||||
|
==== Listing a role's permissions
|
||||||
|
Another command with a similar implementation of the previous ones, the `rep_list_roles` returns a list of the permissions that someone with that role would have on the organization.
|
||||||
|
|
||||||
|
|
||||||
|
==== Listing roles with a permission
|
||||||
|
Once again, this command has an almost identical implementation of the previous commands, the command `rep_list_permission_roles` gives the user a list of roles that have a specific permission, given as an argument before calling said command.
|
||||||
|
|
||||||
|
|
||||||
|
==== Adding a subject to an organization
|
||||||
|
The command `rep_add_subject` aims to give the possibility of a user to be able to add another user to the current organization. This is, of course, a command that requires specific permissions in order to do this.
|
||||||
|
|
||||||
|
First, it checks for the existence of a session file. Then, it sends a `POST` request to the server, with the information of the subject to be added. These include the `username`, `full_name`, `email` and `public_key`. This content is encrypted with the derived key from the Diffie-Hellman key exchange from the user that is executing the command.
|
||||||
|
|
||||||
|
According to the success of the previous request, a message will be printed back to the user.
|
||||||
|
|
||||||
|
|
||||||
|
==== Suspending a subject
|
||||||
|
This command (`rep_suspend_subject`) allows for a user (authenticated and with the required permission) to suspend a subject from an organization.
|
||||||
|
|
||||||
|
The username is parsed in the URL, and the session token is, as usual, sent in the header of the request.
|
||||||
|
|
||||||
|
|
||||||
|
==== Activating a subject
|
||||||
|
The following command `rep_activate_subject` has a similar implementation as the `rep_suspend_subject` command. The key difference is that instead of activating a subject, it suspends a subject. In order to activate a subject, the subject must be in a suspended state.
|
||||||
|
|
||||||
|
==== Adding a document
|
||||||
|
Using the command `rep_add_doc` allows the user to upload a document to the current organization his in. The file given is encrypted with symmetric encryption and the key, the document's name and the algorith used is upload as it's metadata to the server.
|
||||||
|
|
||||||
|
[source, python]
|
||||||
|
----
|
||||||
|
#Encrypt content
|
||||||
|
key, content = encrypt_file(BASE_DIR + args.file, BASE_DIR + 'encryptedText')
|
||||||
|
|
||||||
|
metadata = {'document_name' : args.name, 'key' : key.hex(), 'alg' : 'AES-CFB' }
|
||||||
|
----
|
||||||
|
|
||||||
|
Of course, the information is then encrypted with the derived key located in the session file before sending to the server with a `POST` request.
|
||||||
|
|
||||||
|
[source, python]
|
||||||
|
----
|
||||||
|
# Encrypting metadata
|
||||||
|
derived_key = bytes.fromhex(args.session['derived_key'])
|
||||||
|
metadata = encrypt(metadata, derived_key).hex()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Authorization': args.session['token'],
|
||||||
|
'Content-Type': 'application/octet-stream'
|
||||||
|
}
|
||||||
|
req = requests.post(f'http://{state['REP_ADDRESS']}/file/upload/metadata',
|
||||||
|
data=metadata,
|
||||||
|
headers=headers)
|
||||||
|
----
|
||||||
|
|
||||||
|
If the upload is successful, the client proceeds to upload the encrypted content, along with a hash of the content for the server to check for integrity.
|
||||||
|
|
||||||
|
[source, python]
|
||||||
|
----
|
||||||
|
#Upload Document content
|
||||||
|
file = {'file' : (BASE_DIR + args.file, content)}
|
||||||
|
|
||||||
|
req = requests.post(f'http://{state['REP_ADDRESS']}/file/upload/content',
|
||||||
|
files=file,
|
||||||
|
headers={'Authorization': args.session['token'],
|
||||||
|
'File-Checksum' : digest.get_hash(content)})
|
||||||
|
----
|
||||||
|
|
||||||
|
==== Modifying Access Control List(ACL) from a document
|
||||||
|
In order to modify the ACL of an organization, the user must have the permission `DOC_ACL`. Once that permission is present, the command `rep_acl_doc` can be used to change specific role's permissions such as
|
||||||
|
|
||||||
|
* `DOC_ACL` → Allows the person holding the given role to change the ACL.
|
||||||
|
* `DOC_READ` → Allows the person holding the given role to read the document.
|
||||||
|
* `DOC_DELETE` → Allows the person holding the given role to delete the *file_handle*.
|
||||||
|
|
||||||
|
<<<
|
||||||
|
|
||||||
|
==== Getting a document's metadata
|
||||||
|
Every document has it's metadata that includes the `file handle` and `key`. To get the metadata of a specific document, the command `rep_get_doc_metadata` sends a `GET` request and with the derived key the response is decrypted. Using the `file handle` we can then get the file.
|
||||||
|
|
||||||
|
==== Getting a file's content
|
||||||
|
This command (`rep_get_file`) is relatively simple, the client provides the file handle to the server, and it is given the file's content which is either written on the screen or saved in a file given by the user.
|
||||||
|
|
||||||
|
==== Decrypting a file
|
||||||
|
Given the document's metadata and the file's content, what's left to do is to get the actual content by decrypting it. As such, using the command `rep_decrypt_file` the file's content is decrypted using the key given in its metadata
|
||||||
|
|
||||||
|
[source, python]
|
||||||
|
----
|
||||||
|
content = symmetric_encryption.decrypt_file(bytes.fromhex(metadata['key']), BASE_DIR + args.encrypted)
|
||||||
|
----
|
||||||
|
|
||||||
|
==== Getting a document's file
|
||||||
|
The command `rep_get_doc_file` is essentially the union of the last three commands. The document's name given by the user is hashed and a `GET` request is sent to the server to acquire the metadata.
|
||||||
|
|
||||||
|
[source, python]
|
||||||
|
----
|
||||||
|
doc_name = digest.get_hash(bytes(args.name, encoding='utf-8'))
|
||||||
|
metadata = requests.get(f'http://{state['REP_ADDRESS']}/file/get/' + doc_name + '/metadata', headers={'Authorization': args.session['token']})
|
||||||
|
----
|
||||||
|
|
||||||
|
The metadata is then decrypted, and we get the `file handle` needed to get it's content by sending another `GET` request. If successful, the hash of the file's content must be equal to the file handle, this way we see if the file's integrity is maintained.
|
||||||
|
|
||||||
|
[source, python]
|
||||||
|
----
|
||||||
|
file = requests.get(f'http://{state['REP_ADDRESS']}/file/get/' + metadata['file_handle'] + '/content')
|
||||||
|
file = file.content
|
||||||
|
if not digest.get_hash(file) == metadata['file_handle']:
|
||||||
|
logger.error("File's integrity was lost.")
|
||||||
|
sys.exit(-1)
|
||||||
|
----
|
||||||
|
|
||||||
|
Once having both the metadata and the file's content, the client decrypts it like it's being done in the command `rep_decrypt_file`. The file's content is written to a temporary file, and it's used symmetric decryption to get the plaintext using the key stored in the metadata.
|
||||||
|
|
||||||
|
[source, python]
|
||||||
|
----
|
||||||
|
with open(BASE_DIR + 'encrypted_file', 'wb') as f:
|
||||||
|
f.write(file)
|
||||||
|
|
||||||
|
content = symmetric_encryption.decrypt_file(bytes.fromhex(metadata['key']), BASE_DIR + 'encrypted_file')
|
||||||
|
os.remove(BASE_DIR + 'encrypted_file')
|
||||||
|
----
|
||||||
|
|
||||||
|
==== Listing documents
|
||||||
|
The command `rep_list_docs` allows the user to get a list of all documents in the organization.
|
||||||
|
|
||||||
|
To make it organized, the command can be executed with a date and specify if the documents must be older, newer or created in that specific date, or it can be filtered by who created the document.
|
||||||
|
|
||||||
|
The payload simply contains nothing if there's no subject or date given, or it contains the username, date or both.
|
||||||
|
|
||||||
|
The following example shows how the endpoint may be called specifying the date and the username.
|
||||||
|
|
||||||
|
[source, python]
|
||||||
|
----
|
||||||
|
payload = {} # Payload may be sent empty if no filters are needed
|
||||||
|
|
||||||
|
# If the username is given
|
||||||
|
payload['username'] = args.username[0]
|
||||||
|
# If the date is given
|
||||||
|
payload['datetime'] = {'value': args.date[0], 'relation': args.date[1]}
|
||||||
|
|
||||||
|
payload = json.dumps(payload)
|
||||||
|
payload = encrypt(payload, derived_key).hex()
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Authorization': args.session['token'],
|
||||||
|
'Content-Type': 'application/octet-stream'
|
||||||
|
}
|
||||||
|
req = requests.get(f'http://{state['REP_ADDRESS']}/file/list', data=payload, headers=headers)
|
||||||
|
----
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,23 @@
|
||||||
= SIO Project Report
|
= SIO Project Report
|
||||||
Authors: Rúben Gomes (113435), João Bastos (113470), Tiago Garcia (114184) | 30/12/2024
|
Rúben Gomes (113435); João Bastos (113470); Tiago Garcia (114184)
|
||||||
|
30/12/2024
|
||||||
|
:docdate: 30/12/2024
|
||||||
:toc:
|
:toc:
|
||||||
:toclevels: 3
|
:toclevels: 2
|
||||||
:doctype: article
|
:doctype: article
|
||||||
:source-highlighter: highlightjs
|
:source-highlighter: highlightjs
|
||||||
:icons: font
|
:icons: font
|
||||||
:stylesheet: style.css
|
:stylesheet: style.css
|
||||||
:sectnums:
|
:sectnums:
|
||||||
:sectlinks:
|
:sectlinks:
|
||||||
|
:stem: latexmath
|
||||||
:!last-update-label:
|
:!last-update-label:
|
||||||
|
|
||||||
<<<
|
|
||||||
|
|
||||||
== Introduction
|
== Introduction
|
||||||
|
|
||||||
This document serves as the final report for the SIO-2425 project. This project serves as a way to demonstrate the practical application of some of the concepts learned throughout the course (Authentication, Access Control, Session Management and Stored Cryptography). On a analysis perspective, it will be focused on the V2 (Authentication) chapter of the OWASP ASVS.
|
This document serves as the final report for the SIO-2425 project. This project serves as a way to demonstrate the practical application of some of the concepts learned throughout the course (Authentication, Access Control, Session Management and Stored Cryptography). On a analysis perspective, it will be focused on the V3 (Session Management) chapter of the OWASP ASVS.
|
||||||
|
|
||||||
This report will cover the features implemented, the decisions made as a group, as well as results and conclusions of the project.
|
This report will cover the features implemented, the decisions made as a group, the analysis of the implementation, and finally the results and conclusions of the project.
|
||||||
|
|
||||||
<<<
|
<<<
|
||||||
|
|
||||||
|
|
@ -32,4 +33,4 @@ include::analysis.adoc[]
|
||||||
|
|
||||||
<<<
|
<<<
|
||||||
|
|
||||||
include::results_conclusions.adoc[]
|
include::conclusions.adoc[]
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1,3 +0,0 @@
|
||||||
== Results and Conclusions
|
|
||||||
|
|
||||||
This section will present the outcomes of the project, including any data collected, analyses performed, and key findings.
|
|
||||||
|
|
@ -5,5 +5,28 @@
|
||||||
a::after {
|
a::after {
|
||||||
content: none !important; /* Prevent showing URLs in print */
|
content: none !important; /* Prevent showing URLs in print */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
page-break-after: avoid !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
p, pre {
|
||||||
|
page-break-before: avoid !important;
|
||||||
|
page-break-inside: avoid !important;
|
||||||
|
page-break-after: avoid !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
font-family: 'Open Sans', sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
p:not(.tableblock) {
|
||||||
|
text-align: justify !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.analysis {
|
||||||
|
td:nth-child(2) p {
|
||||||
|
text-align: justify !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue