Page MenuHomedesp's stash

User.java
No OneTemporary

User.java

package me.despawningbone.arithtrain;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.UnsupportedEncodingException;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Iterator;
import java.util.List;
import java.util.Map.Entry;
import java.util.NoSuchElementException;
import java.util.Scanner;
import java.util.TreeMap;
/**
* The utility class for handling user account storage and operations, including score calculations and leveling.<br>
* Third-party programs are expected to utilize this class to interact with the user database instead of their own codes.
*
* @author despawningbone
*/
public class User { //TODO test whole class; make it not reflection accessible and encrypt the database to prevent cheating/password editing?
/**
* A map storing the levels in the form of {@link ExpressionGenerator} objects.<br>
* This allows modular addition of levels from external sources, namely the configuration.
* A TreeMap is utilized due to the strong ordering nature of levels and the ease of setting level thresholds.
*/
public static TreeMap<Double, ExpressionGenerator> levels = new TreeMap<>();
/**
* A static method for creating a user object upon register.<br>
* <i>Instantiating through {@link User#User(String, String)} should be done instead if the user already exists.</i>
* @param name the username of the new user
* @param pw the password of the new user, see {@link User#makePassword(String, String)}
* @param confirm a matching password for confirmation
* @return a new user object
* @throws IOException if the user database is not accessible
*/
public static User register(String name, String pw, String confirm) throws IOException {
if(name == null || name.isEmpty()) throw new IllegalArgumentException("The username cannot be empty!");
if(searchData(name) != null) throw new IllegalArgumentException("This username is already taken!");
File pfile = new File(System.getProperty("user.dir") + File.separator + "users.data");
FileOutputStream out = new FileOutputStream(pfile, true);
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8));
writer.write(name.replaceAll("\\|", "\\\\|").toLowerCase() + "|" //so that the delimiter is unique even if people use it in usernames; case insensitive
+ makePassword(pw, confirm) + "|" //encrypt password with salted MD5; using $ as delimiter because hex strings will not contain it
+ "0|" //score
+ "0\n" ); //answered (to compute accuracy)
writer.close();
out.close();
return new User(name, pw); //redundant login; but still a good check
}
/**
* Generates a salted and hashed password with MD5 for storage in the database.
* <p>
* Third-party programs are not expected to utilize this method, but they should access this method through reflection if they have to edit the database manually.
* @param pw the password which should include both upper case and lower case letters, along with at least one number
* @param confirm a matching password for confirmation
* @return the hashed string in hexadecimal form
*/
private static String makePassword(String pw, String confirm) {
if(!pw.matches("(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9]).*")) throw new IllegalArgumentException("The password entered is not strong enough!\nIt needs to have at least 1 lower case letter, 1 upper case letter and 1 number.");
if(pw.equals(confirm)) {
String salt = generateSalt(8); //TODO make salt length configurable?
return salt + "$" + hash(pw + salt);
} else {
throw new IllegalArgumentException("Passwords does not match!");
}
}
/**
* Compares the hashed password with the password provided.
* @param pw the plaintext password inputted
* @param hash the hashed password stored
* @return true if identical, false otherwise
*/
private static boolean matchPassword(String pw, String hash) {
String[] split = hash.split("\\$");
return hash(pw + split[0]).equals(split[1]);
}
/**
* A helper method for applying MD5 hashing on any unicode passwords.
* @param pw the password to be hashed
* @return the hashed password in hexadecimal form
*/
private static String hash(String pw) {
try {
return new BigInteger(1, MessageDigest.getInstance("MD5").digest(pw.getBytes("UTF-8"))).toString(16);
} catch (UnsupportedEncodingException | NoSuchAlgorithmException e) {
e.printStackTrace(); //should not be possible to have an exception here
return null;
}
}
private static final char[] alphanumeric = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".toCharArray();
/**
* Generates a variable length alphanumeric salt for use in hashing along with {@link User#hash(String)}.
* @param saltLength the desired character length of the salt
* @return the salt
*/
private static String generateSalt(int saltLength) {
SecureRandom ran = new SecureRandom();
char[] chars = new char[saltLength];
for(int i = 0; i < saltLength; i++) {
chars[i] = alphanumeric[ran.nextInt(62)];
}
return new String(chars);
}
/**
* Searches the user database and returns the user data.
* @param name the username of the user to be searched
* @return a string array containing the user account details
* @throws IOException if the user database is not accessible
*/
private static String[] searchData(String name) throws IOException {
if(name == null) return null;
name = name.replaceAll("\\|", "\\\\|");
Scanner scanner = new Scanner(new File(System.getProperty("user.dir") + File.separator + "users.data"), "UTF-8");
while(scanner.hasNextLine()) { //linear search; not putting the file in memory with Files.readLines() because it can be really big
String entry = scanner.nextLine();
String[] split = entry.split("(?<=[^\\\\])\\|");
if(split[0].equalsIgnoreCase(name)) {
scanner.close();
return split;
}
}
scanner.close();
return null;
}
//END STATIC METHODS
private long score;
private long answered;
private String name;
private ExpressionGenerator gen;
private int level;
/**
* Attempts to log a user in, instantiating a {@link User} object on success.
* @param name the username of the user to be logged in
* @param pw the password of the user to be logged in
* @throws IOException if the user database is not accessible
*/
public User(String name, String pw) throws IOException { //perform login
String[] userData = searchData(name);
if(userData == null) {
throw new NoSuchElementException("This user does not exist!");
}
if(!matchPassword(pw, userData[1])) throw new IllegalArgumentException("Wrong password!");
this.score = Long.parseLong(userData[2]);
this.answered = Long.parseLong(userData[3]);
this.name = name;
this.gen = levels.floorEntry(score * getAccuracyRaw()).getValue();
this.level = getLevel();
}
/**
* Gets the user's name.
* @return the username string
*/
public String getName() {
return name;
}
/**
* Gets the display accuracy in percentage form.
* @return the display accuracy
*/
public double getAccuracy() {
return getAccuracyRaw() * 100;
}
/**
* Computes the raw accuracy of the user (from 0-1).
* @return the raw accuracy
*/
public double getAccuracyRaw() {
return answered == 0 ? 1 : ((double) score / answered);
}
/**
* Returns the correct answer count of the user.
* @return the user's score
*/
public long getScore() {
return score;
}
/**
* Returns the {@link ExpressionGenerator} object of the level the user is currently in.
* @return the expression generator
*/
public ExpressionGenerator getCurrentGenerator() {
return gen;
}
/**
* Computes the current percentage from reaching the next level,<br>
* and returns infinity if there is no level after the current one.
* @return the percentage
*/
public double getNextLevelPercent() {
if(levels.ceilingKey(score * getAccuracyRaw()) != null) {
return (score * getAccuracyRaw() - levels.floorKey(score * getAccuracyRaw())) / (levels.ceilingKey(score * getAccuracyRaw()) - levels.floorKey(score * getAccuracyRaw())); //so its the actual range not the whole range
} else {
return Double.POSITIVE_INFINITY;
}
}
/**
* Saves the user object into the user database.
* @throws IOException if the user database is not accessible
*/
public void save() throws IOException { //NOTE: SAVING IS NOT THREAD SAFE!
editData( this.name.replaceAll("\\|", "\\\\|").toLowerCase() + "|" //so that the delimiter is unique even if people use it in usernames; case insensitive
+ searchData(this.name)[1] + "|" //encrypt password with salted MD5; using $ as delimiter because hex strings will not contain it
+ this.score + "|" //score
+ this.answered); //answered (to compute accuracy)
}
/**
* Updates the user's password.<br>
* This function is currently unused in the main arithmetic trainer, however third-party programs are free to utilize this method.
* @param oldPw the original password of the user
* @param newPw the new password of the user, see {@link User#makePassword(String, String)}
* @param confirm a matching password for confirmation
* @throws IOException if the user database is not accessible
*/
public void updatePassword(String oldPw, String newPw, String confirm) throws IOException {
String[] values = searchData(this.name); //searchData should never return null because this is a valid user object
if(matchPassword(oldPw, values[1])) {
values[1] = makePassword(newPw, confirm);
editData(String.join("|", values));
} else {
throw new IllegalArgumentException("Wrong password!");
}
}
/**
* Updates the user's score according to whether he answered correctly or not.
* @param success whether the user succeeded in answering the question correctly
* @return the level the user should be in after the update, can be higher, lower or unchanged.
*/
public int updateScore(boolean success) { //update score based on success; also updates level if needed
if(success) score++;
answered++;
ExpressionGenerator newGen = levels.floorEntry(score * getAccuracyRaw()).getValue();
if(newGen != this.gen) {
this.gen = newGen;
this.level = getLevel();
}
return level; //tells back if the level changed; it can be lower or higher
}
/**
* An internal method for editing the user database directly, changing the entry to be updated according to the username provided in the update string.
* @param update the updated user entry for replacement
* @throws IOException if the user database is not accessible
*/
private void editData(String update) throws IOException { //Files.readLines() is utilized because all methods need to put the file into memory anyways
Path path = Paths.get(System.getProperty("user.dir") + File.separator + "users.data");
List<String> lines = Files.readAllLines(Paths.get(System.getProperty("user.dir") + File.separator + "users.data"), StandardCharsets.UTF_8);
for(int i = 0; i < lines.size(); i++) {
String[] split = lines.get(i).split("(?<=[^\\\\])\\|");
if(split[0].equalsIgnoreCase(this.name.replaceAll("\\|", "\\\\|"))) {
lines.set(i, update);
Files.write(path, lines, StandardCharsets.UTF_8);
return;
}
}
throw new IllegalArgumentException("This user does not exist!");
}
/**
* Gets the current level of the user in integer form.
* @return the level
*/
public int getLevel() {
Iterator<Entry<Double, ExpressionGenerator>> iterator = levels.entrySet().iterator();
int i = 1;
while(iterator.hasNext()) {
if(iterator.next().getValue() == this.gen) {
return i;
}
i++;
}
throw new IllegalStateException("Invalid level!"); //as score * acc will never go down to 0, and the lowest key is 0, reaching this means something has gone wrong
}
}

File Metadata

Mime Type
text/x-java
Expires
Thu, Oct 9, 7:28 AM (10 h, 51 m)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
31/50/0e045bf64df98e1e44d56deec5b0

Event Timeline